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