portal_publico/static/js/links-ferramentas.js

417 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const PID_LINK_DEFAULT_ICON =
'<svg class="lf-card__icon lf-card__icon--default" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><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_LINK_ICON_MAX_BYTES = 2 * 1024 * 1024;
async function pidFetchLinks() {
return (await pidApiRequest("/links-ferramentas/")) || [];
}
async function pidCreateLink(formData) {
return pidApiRequest("/links-ferramentas/", { method: "POST", body: formData });
}
async function pidUpdateLinkOrdem(id, ordem) {
return pidApiRequest(`/links-ferramentas/${id}/`, { method: "PATCH", body: { ordem } });
}
async function pidUpdateLink(id, formData) {
return pidApiRequest(`/links-ferramentas/${id}/`, { method: "PATCH", body: formData });
}
async function pidDeleteLink(id) {
return pidApiRequest(`/links-ferramentas/${id}/`, { method: "DELETE" });
}
async function pidFetchLinkFavoritos() {
const list = await pidApiRequest("/links-ferramentas-favoritos/");
return (list || []).map((f) => f.link);
}
async function pidAddLinkFavorito(linkId) {
return pidApiRequest("/links-ferramentas-favoritos/", { method: "POST", body: { link: linkId } });
}
async function pidRemoveLinkFavorito(linkId) {
return pidApiRequest(`/links-ferramentas-favoritos/${linkId}/`, { method: "DELETE" });
}
document.addEventListener("DOMContentLoaded", async () => {
const grid = document.getElementById("lf-grid");
if (!grid) return;
const me = await pidGetMe();
if (!me) return;
const linkPerms = (me.permissoes_efetivas && me.permissoes_efetivas["links-ferramentas"]) || {};
const canView = !!(linkPerms.apps && linkPerms.apps["links-ferramentas-visualizar"]);
const canManage = !!(linkPerms.apps && linkPerms.apps["links-ferramentas-editar"]);
const noAccessEl = document.getElementById("lf-no-access");
const contentEl = document.getElementById("lf-content");
if (noAccessEl) noAccessEl.hidden = canView;
if (contentEl) contentEl.hidden = !canView;
if (!canView) return;
const emptyEl = document.getElementById("lf-empty");
const addBtn = document.getElementById("lf-add-btn");
const modal = document.getElementById("lf-add-modal");
const formTitle = document.getElementById("lf-form-title");
const formNome = document.getElementById("lf-form-nome");
const formUrl = document.getElementById("lf-form-url");
const formIcone = document.getElementById("lf-form-icone");
const formIconeLabel = document.getElementById("lf-form-icone-label");
const formError = document.getElementById("lf-form-error");
const saveBtn = document.getElementById("lf-form-save-btn");
const cancelBtn = document.getElementById("lf-form-cancel-btn");
if (addBtn) addBtn.hidden = !canManage;
let rawLinks = [];
let links = [];
let favoritos = new Set();
let editingId = null;
// Retrato dos campos na abertura do modal, pra só perguntar "Sair sem
// salvar" quando algo mudou de fato.
let retratoModal = null;
const textoVazio = emptyEl ? emptyEl.textContent : "";
const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
function sortFavoritesFirst(list) {
const fav = [];
const rest = [];
list.forEach((link) => (favoritos.has(link.id) ? fav : rest).push(link));
return fav.concat(rest);
}
function cardHtml(link, index) {
// Tamanho fixo (115px em .lf-card__icon) evita a grade pular enquanto a imagem carrega.
const iconHtml = link.icone
? `<img class="lf-card__icon" src="${pidEscapeHtml(link.icone)}" alt="" width="115" height="115" loading="lazy" />`
: PID_LINK_DEFAULT_ICON;
const isFavorite = favoritos.has(link.id);
const favoriteBtn = `
<button type="button" class="lf-card__favorite${isFavorite ? " is-active" : ""}" data-link-favorite="${link.id}" aria-label="${isFavorite ? "Remover dos favoritos" : "Adicionar aos favoritos"}" aria-pressed="${isFavorite}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="${isFavorite ? "currentColor" : "none"}" stroke="currentColor" stroke-width="1.8"><path d="M12 3l2.9 6.2 6.8.7-5 4.7 1.4 6.7L12 17.9 5.9 21.3l1.4-6.7-5-4.7 6.8-.7z" stroke-linejoin="round"/></svg>
</button>`;
const adminHtml = canManage
? `
<button type="button" class="lf-card__move" data-link-move-up="${link.id}" ${index === 0 ? "disabled" : ""} aria-label="Mover para cima">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button type="button" class="lf-card__move" data-link-move-down="${link.id}" ${index === links.length - 1 ? "disabled" : ""} aria-label="Mover para baixo">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button type="button" class="lf-card__move" data-link-edit="${link.id}" aria-label="Editar link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9" stroke-linecap="round"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" stroke-linejoin="round"/></svg>
</button>
<button type="button" class="lf-card__remove" data-link-remove="${link.id}" aria-label="Remover link">
<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>`
: "";
return `
<article class="lf-card${canManage ? " lf-card--draggable" : ""}" data-link-id="${link.id}" ${canManage ? 'draggable="true"' : ""}>
<div class="lf-card__admin">
${favoriteBtn}
${adminHtml}
</div>
<a class="lf-card__link" href="${pidEscapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" draggable="false">
${iconHtml}
<span class="lf-card__label">${pidEscapeHtml(link.nome)}</span>
</a>
</article>`;
}
function render() {
grid.querySelectorAll(".lf-card").forEach((el) => el.remove());
const cardsHtml = links.map((link, index) => cardHtml(link, index)).join("");
if (emptyEl) {
emptyEl.insertAdjacentHTML("afterend", cardsHtml);
emptyEl.hidden = links.length !== 0;
} else {
grid.insertAdjacentHTML("beforeend", cardsHtml);
}
}
async function refresh() {
try {
const [fetchedLinks, favoriteIds] = await Promise.all([pidFetchLinks(), pidFetchLinkFavoritos()]);
rawLinks = fetchedLinks;
favoritos = new Set(favoriteIds);
} catch (e) {
// Falha de carga não pode parecer "nenhum link cadastrado".
rawLinks = [];
links = [];
render();
if (emptyEl) {
emptyEl.textContent = msgErroCarga;
emptyEl.hidden = false;
}
return;
}
if (emptyEl) emptyEl.textContent = textoVazio;
links = sortFavoritesFirst(rawLinks);
render();
}
function retratoAtual() {
return JSON.stringify([formNome.value, formUrl.value, formIcone.value]);
}
function openModal(link) {
editingId = link ? link.id : null;
formNome.value = link ? link.nome : "";
formUrl.value = link ? link.url : "";
formIcone.value = "";
formError.textContent = "";
formTitle.textContent = link ? "Editar Link" : "Adicionar Link";
formIconeLabel.textContent = link
? "Ícone (deixe em branco para manter o atual, até 2 MB)"
: "Ícone (opcional, até 2 MB)";
saveBtn.textContent = link ? "Salvar" : "Adicionar";
retratoModal = retratoAtual();
modal.hidden = false;
formNome.focus();
}
function closeModal() {
modal.hidden = true;
editingId = null;
retratoModal = null;
}
async function tentarFecharModal() {
if (retratoModal !== null && retratoAtual() !== retratoModal) {
const sair = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (!sair) return;
}
closeModal();
}
// Na falha, uma das duas gravações pode ter passado: recarrega pra tela
// mostrar a ordem real do servidor.
async function swapOrdem(a, b, botao) {
botao.disabled = true;
try {
await Promise.all([pidUpdateLinkOrdem(a.id, b.ordem), pidUpdateLinkOrdem(b.id, a.ordem)]);
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível mover o link" });
}
await refresh();
}
if (addBtn) {
addBtn.addEventListener("click", () => openModal());
}
if (formIcone) {
formIcone.addEventListener("change", () => {
const file = formIcone.files[0];
if (file && file.size > PID_LINK_ICON_MAX_BYTES) {
formError.textContent = "O ícone deve ter no máximo 2 MB.";
formIcone.value = "";
} else {
formError.textContent = "";
}
});
}
if (cancelBtn) cancelBtn.addEventListener("click", tentarFecharModal);
if (modal) {
modal.addEventListener("click", (event) => {
if (event.target === modal) tentarFecharModal();
});
}
if (saveBtn) {
saveBtn.addEventListener("click", async () => {
const nome = formNome.value.trim();
const url = formUrl.value.trim();
if (!nome || !url) {
formError.textContent = "Informe o nome e o link.";
(nome ? formUrl : formNome).focus();
return;
}
if (formIcone.files[0] && formIcone.files[0].size > PID_LINK_ICON_MAX_BYTES) {
formError.textContent = "O ícone deve ter no máximo 2 MB.";
return;
}
formError.textContent = "";
const formData = new FormData();
formData.append("nome", nome);
formData.append("url", url);
if (formIcone.files[0]) formData.append("icone", formIcone.files[0]);
// Trava contra clique duplo (o upload do ícone pode demorar).
const textoOriginal = saveBtn.textContent;
saveBtn.disabled = true;
saveBtn.textContent = editingId ? "Salvando…" : "Adicionando…";
try {
if (editingId) {
await pidUpdateLink(editingId, formData);
} else {
await pidCreateLink(formData);
}
closeModal();
await refresh();
} catch (e) {
formError.textContent = e.message;
} finally {
saveBtn.disabled = false;
saveBtn.textContent = textoOriginal;
}
});
}
grid.addEventListener("click", async (event) => {
const favoriteBtn = event.target.closest("[data-link-favorite]");
if (favoriteBtn) {
const id = Number(favoriteBtn.getAttribute("data-link-favorite"));
// Trava a estrela: dois cliques rápidos mandavam POST duas vezes.
if (favoriteBtn.disabled) return;
favoriteBtn.disabled = true;
try {
if (favoritos.has(id)) {
await pidRemoveLinkFavorito(id);
favoritos.delete(id);
} else {
await pidAddLinkFavorito(id);
favoritos.add(id);
}
links = sortFavoritesFirst(rawLinks);
render();
} catch (e) {
favoriteBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível atualizar o favorito" });
}
return;
}
const editBtn = event.target.closest("[data-link-edit]");
if (editBtn) {
const id = Number(editBtn.getAttribute("data-link-edit"));
const link = links.find((l) => l.id === id);
if (link) openModal(link);
return;
}
const removeBtn = event.target.closest("[data-link-remove]");
if (removeBtn) {
const id = Number(removeBtn.getAttribute("data-link-remove"));
const link = links.find((l) => l.id === id);
if (link && (await pidConfirm(`Remover o link "${link.nome}"?`, { perigoso: true }))) {
removeBtn.disabled = true;
try {
await pidDeleteLink(id);
} catch (e) {
removeBtn.disabled = false;
await pidAlert(e.message, { titulo: "Não foi possível remover o link" });
return;
}
await refresh();
}
return;
}
const upBtn = event.target.closest("[data-link-move-up]");
if (upBtn) {
const id = Number(upBtn.getAttribute("data-link-move-up"));
const index = links.findIndex((l) => l.id === id);
if (index > 0) await swapOrdem(links[index], links[index - 1], upBtn);
return;
}
const downBtn = event.target.closest("[data-link-move-down]");
if (downBtn) {
const id = Number(downBtn.getAttribute("data-link-move-down"));
const index = links.findIndex((l) => l.id === id);
if (index !== -1 && index < links.length - 1) await swapOrdem(links[index], links[index + 1], downBtn);
}
});
let draggedId = null;
let dropTargetId = null;
let dropBefore = true;
function clearDropHighlight() {
grid.querySelectorAll(".lf-card--drop-before, .lf-card--drop-after").forEach((el) => {
el.classList.remove("lf-card--drop-before", "lf-card--drop-after");
});
}
grid.addEventListener("dragstart", (event) => {
const card = event.target.closest(".lf-card--draggable");
if (!card) return;
draggedId = Number(card.dataset.linkId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(draggedId));
requestAnimationFrame(() => card.classList.add("lf-card--dragging"));
});
grid.addEventListener("dragover", (event) => {
if (draggedId === null) return;
const card = event.target.closest(".lf-card--draggable");
if (!card || Number(card.dataset.linkId) === draggedId) return;
event.preventDefault();
const rect = card.getBoundingClientRect();
dropBefore = event.clientX < rect.left + rect.width / 2;
dropTargetId = Number(card.dataset.linkId);
clearDropHighlight();
card.classList.add(dropBefore ? "lf-card--drop-before" : "lf-card--drop-after");
});
grid.addEventListener("drop", async (event) => {
if (draggedId === null) return;
event.preventDefault();
clearDropHighlight();
const fromId = draggedId;
const toId = dropTargetId;
draggedId = null;
dropTargetId = null;
if (toId === null || fromId === toId) return;
const fromIndex = links.findIndex((l) => l.id === fromId);
if (fromIndex === -1) return;
const reordered = links.slice();
const [moved] = reordered.splice(fromIndex, 1);
let insertIndex = reordered.findIndex((l) => l.id === toId);
if (insertIndex === -1) return;
if (!dropBefore) insertIndex += 1;
reordered.splice(insertIndex, 0, moved);
const updates = reordered
.map((link, index) => ({ link, index }))
.filter(({ link, index }) => link.ordem !== index);
if (!updates.length) return;
links = reordered;
render();
try {
await Promise.all(updates.map(({ link, index }) => pidUpdateLinkOrdem(link.id, index)));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível reordenar os links" });
}
// Sempre recarrega: na falha, desfaz a ordem que a tela mostrou antes da hora.
await refresh();
});
grid.addEventListener("dragend", () => {
draggedId = null;
dropTargetId = null;
clearDropHighlight();
grid.querySelectorAll(".lf-card--dragging").forEach((el) => el.classList.remove("lf-card--dragging"));
});
await refresh();
});