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

352 lines
13 KiB
JavaScript

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;
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) {
const iconHtml = link.icone ? `<img class="lf-card__icon" src="${pidEscapeHtml(link.icone)}" alt="" />` : 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) {
rawLinks = [];
favoritos = new Set();
}
links = sortFavoritesFirst(rawLinks);
render();
}
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é 2MB)"
: "Ícone (opcional, até 2MB)";
saveBtn.textContent = link ? "Salvar" : "Adicionar";
modal.hidden = false;
formNome.focus();
}
function closeModal() {
modal.hidden = true;
editingId = null;
}
async function swapOrdem(a, b) {
await Promise.all([pidUpdateLinkOrdem(a.id, b.ordem), pidUpdateLinkOrdem(b.id, a.ordem)]);
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 2MB.";
formIcone.value = "";
} else {
formError.textContent = "";
}
});
}
if (cancelBtn) cancelBtn.addEventListener("click", closeModal);
if (modal) {
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
}
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.";
return;
}
if (formIcone.files[0] && formIcone.files[0].size > PID_LINK_ICON_MAX_BYTES) {
formError.textContent = "O ícone deve ter no máximo 2MB.";
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]);
try {
if (editingId) {
await pidUpdateLink(editingId, formData);
} else {
await pidCreateLink(formData);
}
closeModal();
await refresh();
} catch (e) {
formError.textContent = e.message;
}
});
}
grid.addEventListener("click", async (event) => {
const favoriteBtn = event.target.closest("[data-link-favorite]");
if (favoriteBtn) {
const id = Number(favoriteBtn.getAttribute("data-link-favorite"));
try {
if (favoritos.has(id)) {
await pidRemoveLinkFavorito(id);
favoritos.delete(id);
} else {
await pidAddLinkFavorito(id);
favoritos.add(id);
}
links = sortFavoritesFirst(rawLinks);
render();
} catch (e) {
await pidAlert(e.message);
}
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 }))) {
await pidDeleteLink(id);
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]);
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]);
}
});
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();
await Promise.all(updates.map(({ link, index }) => pidUpdateLinkOrdem(link.id, index)));
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();
});