${favoriteBtn}
${adminHtml}
${iconHtml}
${pidEscapeHtml(link.nome)}
`;
}
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();
});