mirror of
https://github.com/Forward-Future/loopy.git
synced 2026-09-22 05:33:01 +08:00
c21bc6403b
Co-authored-by: mberman84 <mberman84@users.noreply.github.com>
1454 lines
40 KiB
JavaScript
1454 lines
40 KiB
JavaScript
const themeToggle = document.querySelector("#theme-toggle");
|
||
const themeColor = document.querySelector('meta[name="theme-color"]');
|
||
const themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
||
const THEME_STORAGE_KEY = "loop-library-theme";
|
||
|
||
function readStoredTheme() {
|
||
try {
|
||
return window.localStorage.getItem(THEME_STORAGE_KEY);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function applyTheme(theme, persist = false) {
|
||
const isDark = theme === "dark";
|
||
document.documentElement.dataset.theme = isDark ? "dark" : "light";
|
||
|
||
if (themeToggle) {
|
||
themeToggle.setAttribute("aria-pressed", String(isDark));
|
||
themeToggle.setAttribute(
|
||
"aria-label",
|
||
`Switch to ${isDark ? "light" : "dark"} mode`,
|
||
);
|
||
}
|
||
|
||
if (themeColor) {
|
||
themeColor.setAttribute("content", isDark ? "#101010" : "#faf8f7");
|
||
}
|
||
|
||
if (persist) {
|
||
try {
|
||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||
} catch {
|
||
// The selected theme still applies for this page view.
|
||
}
|
||
}
|
||
}
|
||
|
||
applyTheme(
|
||
document.documentElement.dataset.theme === "dark" ? "dark" : "light",
|
||
);
|
||
|
||
if (themeToggle) {
|
||
themeToggle.addEventListener("click", () => {
|
||
const nextTheme =
|
||
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||
applyTheme(nextTheme, true);
|
||
});
|
||
}
|
||
|
||
themeMedia.addEventListener("change", (event) => {
|
||
if (!readStoredTheme()) {
|
||
applyTheme(event.matches ? "dark" : "light");
|
||
}
|
||
});
|
||
|
||
const searchInput = document.querySelector("#loop-search");
|
||
const loopRows = [...document.querySelectorAll(".loop-row")];
|
||
const categoryFilters = [
|
||
...document.querySelectorAll("[data-category-filter]"),
|
||
];
|
||
const sortSelect = document.querySelector("#loop-sort");
|
||
const resultsCount = document.querySelector("#results-count");
|
||
const emptyState = document.querySelector("#empty-state");
|
||
const pagination = document.querySelector("#library-pagination");
|
||
const paginationPrevious = document.querySelector("#pagination-previous");
|
||
const paginationNext = document.querySelector("#pagination-next");
|
||
const paginationStatus = document.querySelector("#pagination-status");
|
||
const toast = document.querySelector("#toast");
|
||
const PAGE_SIZE = 25;
|
||
|
||
const loopTableBody = document.querySelector(".loop-table tbody");
|
||
const loopRowPositions = new Map(
|
||
loopRows.map((row, index) => [row, index]),
|
||
);
|
||
const SORT_OPTIONS = new Set([
|
||
"featured",
|
||
"popular",
|
||
"newest",
|
||
"oldest",
|
||
"alphabetical",
|
||
]);
|
||
|
||
let activeSort = "featured";
|
||
|
||
if (sortSelect) {
|
||
sortSelect.addEventListener("pointerdown", () => {
|
||
sortSelect.classList.add("is-pointer-focused");
|
||
});
|
||
sortSelect.addEventListener("keydown", () => {
|
||
sortSelect.classList.remove("is-pointer-focused");
|
||
});
|
||
sortSelect.addEventListener("blur", () => {
|
||
sortSelect.classList.remove("is-pointer-focused");
|
||
});
|
||
}
|
||
|
||
function rowTitle(row) {
|
||
return normalize(row.querySelector(".loop-title-link")?.textContent ?? "");
|
||
}
|
||
|
||
function compareNewest(a, b) {
|
||
const publishedDifference = (b.dataset.published ?? "").localeCompare(
|
||
a.dataset.published ?? "",
|
||
);
|
||
|
||
if (publishedDifference !== 0) {
|
||
return publishedDifference;
|
||
}
|
||
|
||
return loopRowPositions.get(b) - loopRowPositions.get(a);
|
||
}
|
||
|
||
function comparePopular(a, b) {
|
||
const upvoteDifference =
|
||
Number(b.dataset.upvotes || 0) - Number(a.dataset.upvotes || 0);
|
||
|
||
return upvoteDifference !== 0 ? upvoteDifference : compareNewest(a, b);
|
||
}
|
||
|
||
function compareOldest(a, b) {
|
||
const publishedDifference = (a.dataset.published ?? "").localeCompare(
|
||
b.dataset.published ?? "",
|
||
);
|
||
|
||
if (publishedDifference !== 0) {
|
||
return publishedDifference;
|
||
}
|
||
|
||
return loopRowPositions.get(a) - loopRowPositions.get(b);
|
||
}
|
||
|
||
function compareFeatured(a, b) {
|
||
const featuredDifference =
|
||
Number(b.dataset.featured === "true") -
|
||
Number(a.dataset.featured === "true");
|
||
|
||
if (featuredDifference !== 0) {
|
||
return featuredDifference;
|
||
}
|
||
|
||
return comparePopular(a, b);
|
||
}
|
||
|
||
function applySort(sort) {
|
||
activeSort = SORT_OPTIONS.has(sort) ? sort : "featured";
|
||
|
||
if (sortSelect) {
|
||
sortSelect.value = activeSort;
|
||
}
|
||
|
||
loopRows.sort((a, b) => {
|
||
if (activeSort === "alphabetical") {
|
||
return rowTitle(a).localeCompare(rowTitle(b), undefined, {
|
||
sensitivity: "base",
|
||
});
|
||
}
|
||
|
||
if (activeSort === "popular") {
|
||
return comparePopular(a, b);
|
||
}
|
||
|
||
if (activeSort === "newest") {
|
||
return compareNewest(a, b);
|
||
}
|
||
|
||
return activeSort === "oldest" ? compareOldest(a, b) : compareFeatured(a, b);
|
||
});
|
||
|
||
if (loopTableBody) {
|
||
loopRows.forEach((row) => loopTableBody.append(row));
|
||
}
|
||
}
|
||
|
||
applySort(activeSort);
|
||
|
||
// Snapshot each row's searchable text before the prompt toggle is injected
|
||
// below. Read the loop content rather than the whole row so search does not
|
||
// match interface controls such as "Copy loop" or "Show more".
|
||
const rowSearchText = new Map(
|
||
loopRows.map((row) => {
|
||
const content = row.querySelector(".cell-loop") ?? row;
|
||
return [row, normalize(`${row.dataset.search ?? ""} ${content.textContent}`)];
|
||
}),
|
||
);
|
||
|
||
const promptPreviews = loopRows
|
||
.map((row, index) => {
|
||
const prompt = row.querySelector("[data-prompt]");
|
||
|
||
if (!prompt) {
|
||
return null;
|
||
}
|
||
|
||
const loopSlug =
|
||
row
|
||
.querySelector(".loop-title-link")
|
||
?.getAttribute("href")
|
||
?.split("/")
|
||
.filter(Boolean)
|
||
.at(-1) || `item-${index + 1}`;
|
||
const button = document.createElement("button");
|
||
const preview = { button, prompt, row };
|
||
|
||
prompt.id = `loop-prompt-${loopSlug}`;
|
||
prompt.classList.add("is-collapsible");
|
||
button.className = "loop-prompt-toggle";
|
||
button.type = "button";
|
||
button.hidden = true;
|
||
button.textContent = "Show more";
|
||
button.setAttribute("aria-controls", prompt.id);
|
||
button.setAttribute("aria-expanded", "false");
|
||
prompt.insertAdjacentElement("afterend", button);
|
||
|
||
button.addEventListener("click", () => {
|
||
const isExpanded = button.getAttribute("aria-expanded") === "true";
|
||
const nextExpanded = !isExpanded;
|
||
|
||
button.setAttribute("aria-expanded", String(nextExpanded));
|
||
button.textContent = nextExpanded ? "Show less" : "Show more";
|
||
prompt.classList.toggle("is-expanded", nextExpanded);
|
||
});
|
||
|
||
return preview;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
function syncPromptToggle({ button, prompt, row }) {
|
||
if (row.hidden) {
|
||
return;
|
||
}
|
||
|
||
if (button.getAttribute("aria-expanded") === "true") {
|
||
button.hidden = false;
|
||
return;
|
||
}
|
||
|
||
button.hidden = prompt.scrollHeight <= prompt.clientHeight + 1;
|
||
}
|
||
|
||
function syncVisiblePromptToggles() {
|
||
promptPreviews.forEach(syncPromptToggle);
|
||
}
|
||
|
||
let activeCategory = "all";
|
||
let currentPage = 1;
|
||
let toastTimer;
|
||
|
||
function normalize(value) {
|
||
return value.toLowerCase().trim();
|
||
}
|
||
|
||
function updateLibrary() {
|
||
if (!searchInput || !resultsCount || !emptyState) {
|
||
return;
|
||
}
|
||
|
||
const query = normalize(searchInput.value);
|
||
const matchingRows = loopRows.filter((row) => {
|
||
const searchableText = rowSearchText.get(row) ?? "";
|
||
const matchesSearch =
|
||
query.length === 0 || searchableText.includes(query);
|
||
const matchesCategory =
|
||
activeCategory === "all" || row.dataset.category === activeCategory;
|
||
return matchesSearch && matchesCategory;
|
||
});
|
||
|
||
const totalMatches = matchingRows.length;
|
||
const totalPages = Math.max(1, Math.ceil(totalMatches / PAGE_SIZE));
|
||
currentPage = Math.min(currentPage, totalPages);
|
||
|
||
const pageStart = (currentPage - 1) * PAGE_SIZE;
|
||
const pageEnd = Math.min(pageStart + PAGE_SIZE, totalMatches);
|
||
const pageRows = new Set(matchingRows.slice(pageStart, pageEnd));
|
||
|
||
loopRows.forEach((row) => {
|
||
row.hidden = !pageRows.has(row);
|
||
});
|
||
|
||
if (totalMatches === 0) {
|
||
resultsCount.textContent = "Showing 0 loops";
|
||
} else if (totalMatches === 1) {
|
||
resultsCount.textContent = "Showing 1 loop";
|
||
} else {
|
||
resultsCount.textContent = `Showing ${pageStart + 1}–${pageEnd} of ${totalMatches} loops`;
|
||
}
|
||
|
||
emptyState.hidden = totalMatches !== 0;
|
||
|
||
if (
|
||
pagination &&
|
||
paginationPrevious &&
|
||
paginationNext &&
|
||
paginationStatus
|
||
) {
|
||
pagination.hidden = totalPages <= 1;
|
||
paginationPrevious.disabled = currentPage === 1;
|
||
paginationNext.disabled = currentPage === totalPages;
|
||
paginationStatus.textContent = `Page ${currentPage} of ${totalPages}`;
|
||
}
|
||
|
||
window.requestAnimationFrame(syncVisiblePromptToggles);
|
||
}
|
||
|
||
// Keep the library's state shareable without discarding tracking or other
|
||
// query parameters owned by the surrounding site.
|
||
function syncUrlState(method = "replace") {
|
||
if (typeof window.history?.replaceState !== "function") {
|
||
return;
|
||
}
|
||
|
||
const params = new URLSearchParams(window.location.search);
|
||
const query = searchInput?.value.trim() ?? "";
|
||
|
||
if (query) {
|
||
params.set("q", query);
|
||
} else {
|
||
params.delete("q");
|
||
}
|
||
|
||
if (activeCategory && activeCategory !== "all") {
|
||
params.set("category", activeCategory);
|
||
} else {
|
||
params.delete("category");
|
||
}
|
||
|
||
if (activeSort !== "featured") {
|
||
params.set("sort", activeSort);
|
||
} else {
|
||
params.delete("sort");
|
||
}
|
||
|
||
if (currentPage > 1) {
|
||
params.set("page", String(currentPage));
|
||
} else {
|
||
params.delete("page");
|
||
}
|
||
|
||
const search = params.toString();
|
||
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}${
|
||
window.location.hash
|
||
}`;
|
||
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||
|
||
if (nextUrl === currentUrl) {
|
||
return;
|
||
}
|
||
|
||
if (method === "push" && typeof window.history.pushState === "function") {
|
||
window.history.pushState(null, "", nextUrl);
|
||
} else {
|
||
window.history.replaceState(null, "", nextUrl);
|
||
}
|
||
}
|
||
|
||
function readUrlState() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
|
||
if (searchInput) {
|
||
searchInput.value = params.get("q") ?? "";
|
||
}
|
||
|
||
applyCategory(params.get("category") ?? "all");
|
||
applySort(params.get("sort") ?? "featured");
|
||
|
||
const requestedPage = Number.parseInt(params.get("page") ?? "1", 10);
|
||
currentPage =
|
||
Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||
}
|
||
|
||
function focusFirstVisibleLoop() {
|
||
const firstVisibleTitle = loopRows
|
||
.find((row) => !row.hidden)
|
||
?.querySelector(".loop-title-link");
|
||
|
||
if (!firstVisibleTitle) {
|
||
return;
|
||
}
|
||
|
||
window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(() => {
|
||
firstVisibleTitle.focus({ preventScroll: true });
|
||
firstVisibleTitle.scrollIntoView({ behavior: "instant", block: "start" });
|
||
});
|
||
});
|
||
}
|
||
|
||
if (searchInput) {
|
||
const resetSearchPage = () => {
|
||
currentPage = 1;
|
||
updateLibrary();
|
||
syncUrlState("replace");
|
||
};
|
||
|
||
searchInput.addEventListener("input", resetSearchPage);
|
||
searchInput.addEventListener("search", resetSearchPage);
|
||
}
|
||
|
||
function applyCategory(category) {
|
||
const known = categoryFilters.some(
|
||
(filter) => filter.dataset.categoryFilter === category,
|
||
);
|
||
activeCategory = known ? category : "all";
|
||
|
||
categoryFilters.forEach((candidate) => {
|
||
const isActive = candidate.dataset.categoryFilter === activeCategory;
|
||
candidate.classList.toggle("is-active", isActive);
|
||
candidate.setAttribute("aria-pressed", String(isActive));
|
||
});
|
||
}
|
||
|
||
categoryFilters.forEach((filter) => {
|
||
filter.addEventListener("click", () => {
|
||
applyCategory(filter.dataset.categoryFilter);
|
||
currentPage = 1;
|
||
updateLibrary();
|
||
syncUrlState("push");
|
||
});
|
||
});
|
||
|
||
if (sortSelect) {
|
||
sortSelect.addEventListener("change", () => {
|
||
applySort(sortSelect.value);
|
||
currentPage = 1;
|
||
updateLibrary();
|
||
syncUrlState("push");
|
||
});
|
||
}
|
||
|
||
const clearFiltersButton = document.querySelector("#clear-filters");
|
||
|
||
if (clearFiltersButton) {
|
||
clearFiltersButton.addEventListener("click", () => {
|
||
if (searchInput) {
|
||
searchInput.value = "";
|
||
}
|
||
|
||
applyCategory("all");
|
||
currentPage = 1;
|
||
updateLibrary();
|
||
syncUrlState("push");
|
||
searchInput?.focus();
|
||
});
|
||
}
|
||
|
||
if (paginationPrevious) {
|
||
paginationPrevious.addEventListener("click", () => {
|
||
if (currentPage > 1) {
|
||
currentPage -= 1;
|
||
updateLibrary();
|
||
syncUrlState("push");
|
||
focusFirstVisibleLoop();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (paginationNext) {
|
||
paginationNext.addEventListener("click", () => {
|
||
currentPage += 1;
|
||
updateLibrary();
|
||
syncUrlState("push");
|
||
focusFirstVisibleLoop();
|
||
});
|
||
}
|
||
|
||
readUrlState();
|
||
updateLibrary();
|
||
syncUrlState("replace");
|
||
|
||
window.addEventListener("popstate", () => {
|
||
readUrlState();
|
||
updateLibrary();
|
||
});
|
||
|
||
let promptResizeFrame;
|
||
window.addEventListener("resize", () => {
|
||
window.cancelAnimationFrame(promptResizeFrame);
|
||
promptResizeFrame = window.requestAnimationFrame(syncVisiblePromptToggles);
|
||
});
|
||
|
||
function showToast(message) {
|
||
if (!toast) {
|
||
return;
|
||
}
|
||
|
||
window.clearTimeout(toastTimer);
|
||
toast.textContent = message;
|
||
toast.classList.add("is-visible");
|
||
toastTimer = window.setTimeout(() => {
|
||
toast.classList.remove("is-visible");
|
||
}, 2200);
|
||
}
|
||
|
||
async function copyText(text) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return;
|
||
} catch {
|
||
// Fall through to the selection-based copy path.
|
||
}
|
||
}
|
||
|
||
const textArea = document.createElement("textarea");
|
||
textArea.value = text;
|
||
textArea.setAttribute("readonly", "");
|
||
textArea.style.position = "fixed";
|
||
textArea.style.opacity = "0";
|
||
document.body.append(textArea);
|
||
textArea.select();
|
||
const copied = document.execCommand("copy");
|
||
textArea.remove();
|
||
|
||
if (!copied) {
|
||
throw new Error("Copy is unavailable.");
|
||
}
|
||
}
|
||
|
||
function showCopyFallback(button, text) {
|
||
const shareActions = button.closest(".share-actions");
|
||
|
||
if (!shareActions) {
|
||
showToast("Copy failed. Copying is unavailable in this browser.");
|
||
return;
|
||
}
|
||
|
||
let fallback = shareActions.querySelector(".copy-fallback");
|
||
|
||
if (!fallback) {
|
||
fallback = document.createElement("label");
|
||
fallback.className = "copy-fallback";
|
||
|
||
const label = document.createElement("span");
|
||
label.textContent = "Copy this post manually";
|
||
|
||
const textArea = document.createElement("textarea");
|
||
textArea.setAttribute("readonly", "");
|
||
textArea.rows = 4;
|
||
|
||
fallback.append(label, textArea);
|
||
shareActions.append(fallback);
|
||
}
|
||
|
||
const textArea = fallback.querySelector("textarea");
|
||
textArea.value = text;
|
||
textArea.focus();
|
||
textArea.select();
|
||
showToast("Copy failed. The post is selected below.");
|
||
}
|
||
|
||
document.querySelectorAll(".copy-button").forEach((button) => {
|
||
const label = button.querySelector("span");
|
||
const defaultLabel = label?.textContent;
|
||
let copyLabelResetTimer;
|
||
|
||
button.addEventListener("click", async () => {
|
||
const copyRoot = button.closest("[data-copy-root]");
|
||
const prompt = copyRoot?.querySelector("[data-prompt]")?.textContent.trim();
|
||
|
||
if (!prompt || !label || !defaultLabel) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await copyText(prompt.replace(/\s+/g, " "));
|
||
label.textContent = "Copied";
|
||
showToast("Loop copied to clipboard.");
|
||
window.clearTimeout(copyLabelResetTimer);
|
||
copyLabelResetTimer = window.setTimeout(() => {
|
||
label.textContent = defaultLabel;
|
||
}, 1800);
|
||
} catch {
|
||
showToast("Copy failed. Select the prompt text instead.");
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("[data-copy-social-post]").forEach((button) => {
|
||
const label = button.querySelector("span");
|
||
const postText = button.dataset.postText;
|
||
const postUrl = button.dataset.postUrl;
|
||
const defaultLabel = label?.textContent;
|
||
let copyLabelResetTimer;
|
||
|
||
button.addEventListener("click", async () => {
|
||
if (!label || !postText || !postUrl || !defaultLabel) {
|
||
return;
|
||
}
|
||
|
||
const socialPost = `${postText}\n\n${postUrl}`;
|
||
|
||
try {
|
||
await copyText(socialPost);
|
||
label.textContent = "Copied!";
|
||
button.classList.add("is-copied");
|
||
showToast("Social post copied to clipboard.");
|
||
window.clearTimeout(copyLabelResetTimer);
|
||
copyLabelResetTimer = window.setTimeout(() => {
|
||
label.textContent = defaultLabel;
|
||
button.classList.remove("is-copied");
|
||
}, 1800);
|
||
} catch {
|
||
showCopyFallback(button, socialPost);
|
||
}
|
||
});
|
||
});
|
||
|
||
const voteControls = [...document.querySelectorAll("[data-vote-controls]")];
|
||
const LOOP_LIBRARY_PATH = window.location.pathname === "/loop-library" ||
|
||
window.location.pathname.startsWith("/loop-library/")
|
||
? "/loop-library"
|
||
: "";
|
||
const VOTE_API_URL = `${LOOP_LIBRARY_PATH}/api/votes`;
|
||
const VOTE_SESSION_KEY = "ll_session";
|
||
const OAUTH_NONCE_KEY = "ll_oauth_nonce";
|
||
let voteViewer = null;
|
||
let viewerVotes = {};
|
||
let loginDialog;
|
||
|
||
function voteLoginUrl(provider) {
|
||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||
return `${LOOP_LIBRARY_PATH}/auth/${provider}?${new URLSearchParams({
|
||
return_to: returnTo,
|
||
})}`;
|
||
}
|
||
|
||
function readVoteSessionToken() {
|
||
try {
|
||
return window.sessionStorage.getItem(VOTE_SESSION_KEY) || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function clearVoteSessionToken() {
|
||
try {
|
||
window.sessionStorage.removeItem(VOTE_SESSION_KEY);
|
||
} catch {
|
||
// Storage may be unavailable in hardened browser modes.
|
||
}
|
||
}
|
||
|
||
function oauthNonce() {
|
||
const bytes = new Uint8Array(32);
|
||
window.crypto.getRandomValues(bytes);
|
||
let binary = "";
|
||
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||
return window.btoa(binary)
|
||
.replace(/\+/g, "-")
|
||
.replace(/\//g, "_")
|
||
.replace(/=+$/, "");
|
||
}
|
||
|
||
async function beginGithubLogin(link) {
|
||
const nonce = oauthNonce();
|
||
try {
|
||
window.sessionStorage.setItem(OAUTH_NONCE_KEY, nonce);
|
||
const url = new URL(link.href, window.location.href);
|
||
url.searchParams.set("client_nonce", nonce);
|
||
const response = await fetch(url, {
|
||
credentials: "same-origin",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok || !body.authorizationUrl) {
|
||
throw new Error(body.error || "GitHub sign-in is unavailable.");
|
||
}
|
||
window.location.assign(body.authorizationUrl);
|
||
} catch (error) {
|
||
try {
|
||
window.sessionStorage.removeItem(OAUTH_NONCE_KEY);
|
||
} catch {
|
||
// Storage may be unavailable in hardened browser modes.
|
||
}
|
||
link.removeAttribute("aria-disabled");
|
||
showToast(error.message || "GitHub sign-in is unavailable.");
|
||
}
|
||
}
|
||
|
||
function createLoginDialog() {
|
||
if (loginDialog) {
|
||
return loginDialog;
|
||
}
|
||
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = "login-dialog";
|
||
dialog.setAttribute("aria-labelledby", "vote-login-title");
|
||
|
||
const panel = document.createElement("div");
|
||
panel.className = "login-dialog-panel";
|
||
const eyebrow = document.createElement("p");
|
||
eyebrow.className = "eyebrow";
|
||
eyebrow.textContent = "Community voting";
|
||
const title = document.createElement("h2");
|
||
title.id = "vote-login-title";
|
||
title.textContent = "Sign in to vote";
|
||
const explanation = document.createElement("p");
|
||
explanation.textContent =
|
||
"Use GitHub. One account gets one vote per loop, and you can change it anytime.";
|
||
const providers = document.createElement("div");
|
||
providers.className = "login-providers";
|
||
|
||
const githubLink = document.createElement("a");
|
||
githubLink.className = "login-provider login-provider-github";
|
||
githubLink.href = voteLoginUrl("github");
|
||
githubLink.textContent = "Continue with GitHub";
|
||
githubLink.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
if (githubLink.getAttribute("aria-disabled") === "true") return;
|
||
githubLink.setAttribute("aria-disabled", "true");
|
||
beginGithubLogin(githubLink);
|
||
});
|
||
providers.append(githubLink);
|
||
|
||
const cancel = document.createElement("button");
|
||
cancel.className = "login-cancel";
|
||
cancel.type = "button";
|
||
cancel.textContent = "Not now";
|
||
cancel.addEventListener("click", () => dialog.close());
|
||
dialog.addEventListener("click", (event) => {
|
||
if (event.target === dialog) dialog.close();
|
||
});
|
||
|
||
panel.append(eyebrow, title, explanation, providers, cancel);
|
||
dialog.append(panel);
|
||
document.body.append(dialog);
|
||
loginDialog = dialog;
|
||
return dialog;
|
||
}
|
||
|
||
function showLoginDialog() {
|
||
const dialog = createLoginDialog();
|
||
if (typeof dialog.showModal === "function") {
|
||
dialog.showModal();
|
||
} else {
|
||
dialog.setAttribute("open", "");
|
||
}
|
||
}
|
||
|
||
window.addEventListener("pageshow", () => {
|
||
loginDialog?.querySelector(".login-provider-github")
|
||
?.removeAttribute("aria-disabled");
|
||
});
|
||
|
||
function updateVoteControls(control, counts = {}, viewerVote = 0) {
|
||
const upvote = control.querySelector('[data-vote-value="1"]');
|
||
const downvote = control.querySelector('[data-vote-value="-1"]');
|
||
const upvoteCount = upvote?.querySelector("[data-vote-count]");
|
||
const downvoteCount = downvote?.querySelector("[data-vote-count]");
|
||
const row = control.closest(".loop-row");
|
||
|
||
if (upvoteCount) upvoteCount.textContent = String(counts.upvotes || 0);
|
||
if (downvoteCount) downvoteCount.textContent = String(counts.downvotes || 0);
|
||
if (row) row.dataset.upvotes = String(counts.upvotes || 0);
|
||
upvote?.setAttribute("aria-pressed", String(viewerVote === 1));
|
||
downvote?.setAttribute("aria-pressed", String(viewerVote === -1));
|
||
control.classList.toggle("has-upvote", viewerVote === 1);
|
||
control.classList.toggle("has-downvote", viewerVote === -1);
|
||
}
|
||
|
||
function clearViewerVoteSelection() {
|
||
voteControls.forEach((control) => {
|
||
control.classList.remove("has-upvote", "has-downvote");
|
||
control.querySelectorAll(".vote-button").forEach((button) => {
|
||
button.setAttribute("aria-pressed", "false");
|
||
});
|
||
});
|
||
}
|
||
|
||
function setVotingUiVisible(visible) {
|
||
voteControls.forEach((control) => {
|
||
control.hidden = !visible;
|
||
control.querySelectorAll(".vote-button").forEach((button) => {
|
||
button.disabled = !visible;
|
||
});
|
||
});
|
||
if (!visible) {
|
||
document.querySelector("[data-vote-account]")?.remove();
|
||
}
|
||
}
|
||
|
||
function renderVoteAccount() {
|
||
const existing = document.querySelector("[data-vote-account]");
|
||
if (existing) existing.remove();
|
||
if (!voteViewer) return;
|
||
|
||
const account = document.createElement("div");
|
||
account.className = "vote-account";
|
||
account.dataset.voteAccount = "";
|
||
const label = document.createElement("span");
|
||
label.textContent = `Voting as GitHub: ${voteViewer.username}`;
|
||
const logout = document.createElement("button");
|
||
logout.type = "button";
|
||
logout.textContent = "Sign out";
|
||
logout.addEventListener("click", () => {
|
||
clearVoteSessionToken();
|
||
voteViewer = null;
|
||
viewerVotes = {};
|
||
renderVoteAccount();
|
||
clearViewerVoteSelection();
|
||
showToast("Signed out.");
|
||
});
|
||
account.append(label, logout);
|
||
|
||
const anchor = document.querySelector(".results-line") ||
|
||
document.querySelector(".detail-actions");
|
||
anchor?.insertAdjacentElement("afterend", account);
|
||
}
|
||
|
||
async function loadVotes() {
|
||
if (voteControls.length === 0) return;
|
||
|
||
try {
|
||
const response = await fetch(VOTE_API_URL, {
|
||
credentials: "same-origin",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!response.ok) throw new Error("Voting unavailable");
|
||
const body = await response.json();
|
||
voteViewer = null;
|
||
viewerVotes = {};
|
||
const sessionToken = readVoteSessionToken();
|
||
if (sessionToken) {
|
||
try {
|
||
const sessionResponse = await fetch(`${LOOP_LIBRARY_PATH}/auth/session`, {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ sessionToken }),
|
||
});
|
||
if (!sessionResponse.ok) {
|
||
if (sessionResponse.status < 500) clearVoteSessionToken();
|
||
} else {
|
||
const sessionBody = await sessionResponse.json();
|
||
if (sessionBody.viewer) {
|
||
voteViewer = sessionBody.viewer;
|
||
viewerVotes = sessionBody.viewerVotes || {};
|
||
} else {
|
||
clearVoteSessionToken();
|
||
}
|
||
}
|
||
} catch {
|
||
// Public totals and the launch gate remain usable when restoring an
|
||
// existing session is temporarily unavailable.
|
||
}
|
||
}
|
||
voteControls.forEach((control) => {
|
||
const slug = control.dataset.loopSlug;
|
||
updateVoteControls(
|
||
control,
|
||
body.votes?.[slug],
|
||
viewerVotes[slug] || 0,
|
||
);
|
||
});
|
||
setVotingUiVisible(body.uiEnabled === true);
|
||
applySort(activeSort);
|
||
updateLibrary();
|
||
if (body.uiEnabled === true) renderVoteAccount();
|
||
} catch {
|
||
setVotingUiVisible(false);
|
||
}
|
||
}
|
||
|
||
voteControls.forEach((control) => {
|
||
control.querySelectorAll("[data-vote-value]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
if (!voteViewer) {
|
||
showLoginDialog();
|
||
return;
|
||
}
|
||
|
||
const slug = control.dataset.loopSlug;
|
||
const selectedValue = Number(button.dataset.voteValue);
|
||
const nextValue = viewerVotes[slug] === selectedValue ? 0 : selectedValue;
|
||
control.querySelectorAll(".vote-button").forEach((candidate) => {
|
||
candidate.disabled = true;
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`${LOOP_LIBRARY_PATH}/api/loops/${encodeURIComponent(slug)}/vote`,
|
||
{
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: {
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
value: nextValue,
|
||
sessionToken: readVoteSessionToken(),
|
||
}),
|
||
},
|
||
);
|
||
const body = await response.json();
|
||
if (response.status === 401) {
|
||
clearVoteSessionToken();
|
||
voteViewer = null;
|
||
viewerVotes = {};
|
||
renderVoteAccount();
|
||
clearViewerVoteSelection();
|
||
showLoginDialog();
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(body.error || "Vote failed");
|
||
viewerVotes[slug] = body.vote;
|
||
updateVoteControls(control, body.counts, body.vote);
|
||
applySort(activeSort);
|
||
updateLibrary();
|
||
showToast(body.vote === 0 ? "Vote removed." : "Vote counted.");
|
||
} catch (error) {
|
||
showToast(error.message || "Vote failed. Try again.");
|
||
} finally {
|
||
control.querySelectorAll(".vote-button").forEach((candidate) => {
|
||
candidate.disabled = false;
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
const authError = new URLSearchParams(window.location.search).get("auth_error");
|
||
if (authError) {
|
||
showToast("Sign-in did not finish. Please try again.");
|
||
const cleanUrl = new URL(window.location.href);
|
||
cleanUrl.searchParams.delete("auth_error");
|
||
window.history.replaceState(null, "", cleanUrl);
|
||
}
|
||
|
||
loadVotes();
|
||
|
||
const skillCopyButton = document.querySelector("[data-copy-skill-command]");
|
||
const skillInstallCommand = document.querySelector(
|
||
"[data-skill-install-command]",
|
||
);
|
||
|
||
if (skillCopyButton && skillInstallCommand) {
|
||
skillCopyButton.addEventListener("click", async () => {
|
||
const label = skillCopyButton.querySelector("span");
|
||
const command = skillInstallCommand.textContent.replace(/\s+/g, " ").trim();
|
||
|
||
if (!label || !command) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await copyText(command);
|
||
label.textContent = "Copied";
|
||
showToast("Install command copied to clipboard.");
|
||
window.setTimeout(() => {
|
||
label.textContent = "Copy command";
|
||
}, 1800);
|
||
} catch {
|
||
showToast("Copy failed. Select the install command instead.");
|
||
}
|
||
});
|
||
}
|
||
|
||
const form = document.querySelector("#loop-form");
|
||
const formStatus = document.querySelector("#form-status");
|
||
const submitButton = form?.querySelector(".submit-button");
|
||
const submitButtonLabel = submitButton?.querySelector("span");
|
||
const weeklyForm = document.querySelector("#weekly-form");
|
||
const weeklyStatus = document.querySelector("#weekly-status");
|
||
const weeklyButton = weeklyForm?.querySelector(".newsletter-button");
|
||
const weeklyButtonLabel = weeklyButton?.querySelector("span");
|
||
const formApiMeta = document.querySelector(
|
||
'meta[name="loop-library-form-api"]',
|
||
);
|
||
const configuredFormApiOrigin = formApiMeta?.getAttribute("content") || "";
|
||
const isLocalPreview = ["localhost", "127.0.0.1"].includes(
|
||
window.location.hostname,
|
||
);
|
||
const FORM_API_ORIGIN = isLocalPreview
|
||
? "http://localhost:8787"
|
||
: configuredFormApiOrigin;
|
||
|
||
let formStartedAt = performance.now();
|
||
let idempotencyKey = makeIdempotencyKey();
|
||
let weeklyFormStartedAt = performance.now();
|
||
let weeklyIdempotencyKey = makeIdempotencyKey();
|
||
let formProtectionReady = false;
|
||
let turnstileLoadPromise;
|
||
|
||
const turnstileWidgets = {
|
||
suggestions: {
|
||
action: "",
|
||
button: submitButton,
|
||
container: document.querySelector("#loop-turnstile"),
|
||
failureMessage:
|
||
"Spam protection is temporarily unavailable. Refresh and try again.",
|
||
id: null,
|
||
pendingMessage:
|
||
"Spam protection is still loading. Wait a moment and try again.",
|
||
setStatus: setFormStatus,
|
||
statusElement: formStatus,
|
||
token: "",
|
||
},
|
||
weeklySignups: {
|
||
action: "",
|
||
button: weeklyButton,
|
||
container: document.querySelector("#weekly-turnstile"),
|
||
failureMessage:
|
||
"Signups are temporarily unavailable. Refresh and try again.",
|
||
id: null,
|
||
pendingMessage:
|
||
"Spam protection is still loading. Wait a moment and try again.",
|
||
setStatus: setWeeklyStatus,
|
||
statusElement: weeklyStatus,
|
||
token: "",
|
||
},
|
||
};
|
||
|
||
function makeIdempotencyKey() {
|
||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||
return window.crypto.randomUUID();
|
||
}
|
||
|
||
const bytes = new Uint8Array(16);
|
||
|
||
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
||
window.crypto.getRandomValues(bytes);
|
||
} else {
|
||
for (let index = 0; index < bytes.length; index += 1) {
|
||
bytes[index] = Math.floor(Math.random() * 256);
|
||
}
|
||
}
|
||
|
||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||
|
||
const hex = [...bytes].map((byte) =>
|
||
byte.toString(16).padStart(2, "0"),
|
||
);
|
||
|
||
return [
|
||
hex.slice(0, 4).join(""),
|
||
hex.slice(4, 6).join(""),
|
||
hex.slice(6, 8).join(""),
|
||
hex.slice(8, 10).join(""),
|
||
hex.slice(10).join(""),
|
||
].join("-");
|
||
}
|
||
|
||
function setFormStatus(message, kind = "") {
|
||
if (!formStatus) {
|
||
return;
|
||
}
|
||
|
||
formStatus.textContent = message;
|
||
formStatus.classList.toggle("is-success", kind === "success");
|
||
formStatus.classList.toggle("is-error", kind === "error");
|
||
}
|
||
|
||
function setWeeklyStatus(message, kind = "") {
|
||
if (!weeklyStatus) {
|
||
return;
|
||
}
|
||
|
||
weeklyStatus.textContent = message;
|
||
weeklyStatus.classList.toggle("is-success", kind === "success");
|
||
weeklyStatus.classList.toggle("is-error", kind === "error");
|
||
}
|
||
|
||
function optionalValue(formData, name) {
|
||
const value = String(formData.get(name) || "").trim();
|
||
return value || undefined;
|
||
}
|
||
|
||
function setProtectedButtonsDisabled(disabled) {
|
||
if (submitButton) {
|
||
submitButton.disabled = disabled;
|
||
}
|
||
|
||
if (weeklyButton) {
|
||
weeklyButton.disabled = disabled;
|
||
}
|
||
}
|
||
|
||
function setTurnstilePending(widget) {
|
||
widget.token = "";
|
||
|
||
if (widget.button) {
|
||
widget.button.disabled = true;
|
||
}
|
||
}
|
||
|
||
function setTurnstileReady(widget, token) {
|
||
widget.token = token;
|
||
|
||
if (
|
||
widget.statusElement &&
|
||
[widget.failureMessage, widget.pendingMessage].includes(
|
||
widget.statusElement.textContent,
|
||
)
|
||
) {
|
||
widget.setStatus("");
|
||
}
|
||
|
||
if (widget.button) {
|
||
widget.button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function setTurnstileUnavailable(widget) {
|
||
setTurnstilePending(widget);
|
||
widget.setStatus(widget.failureMessage, "error");
|
||
}
|
||
|
||
function loadTurnstile() {
|
||
if (window.turnstile) {
|
||
return Promise.resolve(window.turnstile);
|
||
}
|
||
|
||
if (turnstileLoadPromise) {
|
||
return turnstileLoadPromise;
|
||
}
|
||
|
||
turnstileLoadPromise = new Promise((resolve, reject) => {
|
||
const callbackName = "loopLibraryTurnstileReady";
|
||
const script = document.createElement("script");
|
||
|
||
window[callbackName] = () => {
|
||
delete window[callbackName];
|
||
|
||
if (window.turnstile) {
|
||
resolve(window.turnstile);
|
||
} else {
|
||
reject(new Error("Spam protection did not initialize."));
|
||
}
|
||
};
|
||
|
||
script.src =
|
||
"https://challenges.cloudflare.com/turnstile/v0/api.js" +
|
||
`?render=explicit&onload=${callbackName}`;
|
||
script.async = true;
|
||
script.defer = true;
|
||
script.onerror = () => {
|
||
delete window[callbackName];
|
||
reject(new Error("Spam protection could not be loaded."));
|
||
};
|
||
document.head.append(script);
|
||
});
|
||
|
||
return turnstileLoadPromise;
|
||
}
|
||
|
||
function renderTurnstile(turnstile, widget, siteKey) {
|
||
if (!widget.container || !widget.action) {
|
||
throw new Error("Spam protection is not configured.");
|
||
}
|
||
|
||
widget.id = turnstile.render(widget.container, {
|
||
sitekey: siteKey,
|
||
action: widget.action,
|
||
appearance: "interaction-only",
|
||
execution: "render",
|
||
size: "flexible",
|
||
theme:
|
||
document.documentElement.dataset.theme === "dark" ? "dark" : "light",
|
||
callback(token) {
|
||
setTurnstileReady(widget, token);
|
||
},
|
||
"expired-callback"() {
|
||
setTurnstilePending(widget);
|
||
},
|
||
"error-callback"() {
|
||
setTurnstileUnavailable(widget);
|
||
},
|
||
});
|
||
}
|
||
|
||
function resetTurnstile(widget) {
|
||
setTurnstilePending(widget);
|
||
|
||
if (window.turnstile && widget.id !== null) {
|
||
window.turnstile.reset(widget.id);
|
||
}
|
||
}
|
||
|
||
async function initializeFormProtection() {
|
||
if (
|
||
!FORM_API_ORIGIN ||
|
||
!form ||
|
||
!weeklyForm ||
|
||
!submitButton ||
|
||
!weeklyButton
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setProtectedButtonsDisabled(true);
|
||
|
||
try {
|
||
const [configResponse, turnstile] = await Promise.all([
|
||
fetch(`${FORM_API_ORIGIN}/config`, {
|
||
headers: { Accept: "application/json" },
|
||
}),
|
||
loadTurnstile(),
|
||
]);
|
||
const config = await configResponse.json();
|
||
|
||
if (
|
||
!configResponse.ok ||
|
||
!config.turnstileSiteKey ||
|
||
!config.actions?.suggestions ||
|
||
!config.actions?.weeklySignups
|
||
) {
|
||
throw new Error(
|
||
config.error || "Spam protection is temporarily unavailable.",
|
||
);
|
||
}
|
||
|
||
turnstileWidgets.suggestions.action = config.actions.suggestions;
|
||
turnstileWidgets.weeklySignups.action = config.actions.weeklySignups;
|
||
renderTurnstile(
|
||
turnstile,
|
||
turnstileWidgets.suggestions,
|
||
config.turnstileSiteKey,
|
||
);
|
||
renderTurnstile(
|
||
turnstile,
|
||
turnstileWidgets.weeklySignups,
|
||
config.turnstileSiteKey,
|
||
);
|
||
formProtectionReady = true;
|
||
} catch {
|
||
setFormStatus(
|
||
"Submissions are temporarily unavailable. Refresh and try again.",
|
||
"error",
|
||
);
|
||
setWeeklyStatus(
|
||
"Signups are temporarily unavailable. Refresh and try again.",
|
||
"error",
|
||
);
|
||
}
|
||
}
|
||
|
||
function formatRetryHint(seconds) {
|
||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||
return "";
|
||
}
|
||
|
||
if (seconds < 90) {
|
||
const rounded = Math.max(1, Math.ceil(seconds));
|
||
return ` Try again in about ${rounded} second${rounded === 1 ? "" : "s"}.`;
|
||
}
|
||
|
||
const minutes = Math.max(1, Math.ceil(seconds / 60));
|
||
return ` Try again in about ${minutes} minute${minutes === 1 ? "" : "s"}.`;
|
||
}
|
||
|
||
async function postProtectedForm(path, body, fallbackMessage) {
|
||
const response = await fetch(`${FORM_API_ORIGIN}${path}`, {
|
||
method: "POST",
|
||
headers: {
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
let responseBody = {};
|
||
try {
|
||
responseBody = await response.json();
|
||
} catch {
|
||
responseBody = {};
|
||
}
|
||
|
||
if (!response.ok) {
|
||
let message = responseBody.error || fallbackMessage;
|
||
|
||
if (response.status === 429) {
|
||
message += formatRetryHint(Number(response.headers.get("Retry-After")));
|
||
}
|
||
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
if (form && submitButton && submitButtonLabel) {
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
setFormStatus("");
|
||
|
||
if (!form.checkValidity()) {
|
||
form.reportValidity();
|
||
setFormStatus(
|
||
"Check the required fields and any optional values you entered.",
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData(form);
|
||
|
||
if (performance.now() - formStartedAt < 1200) {
|
||
setFormStatus(
|
||
"Take a moment to review the loop, then submit it again.",
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!formProtectionReady) {
|
||
setFormStatus(
|
||
"Submissions are temporarily unavailable. Refresh and try again.",
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!turnstileWidgets.suggestions.token) {
|
||
setFormStatus(
|
||
turnstileWidgets.suggestions.pendingMessage,
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
loop_title: String(formData.get("loop_title")).trim(),
|
||
instructions: String(formData.get("instructions")).trim(),
|
||
};
|
||
|
||
const name = optionalValue(formData, "name");
|
||
const xHandle = optionalValue(formData, "x_handle");
|
||
const sourceUrl = optionalValue(formData, "source_url");
|
||
|
||
if (name) {
|
||
payload.name = name;
|
||
}
|
||
|
||
if (xHandle) {
|
||
payload.x_handle = xHandle;
|
||
}
|
||
|
||
if (sourceUrl) {
|
||
payload.source_url = sourceUrl;
|
||
}
|
||
|
||
submitButton.disabled = true;
|
||
submitButtonLabel.textContent = "Sending";
|
||
|
||
try {
|
||
await postProtectedForm(
|
||
"/suggestions",
|
||
{
|
||
payload,
|
||
permission: formData.get("permission") === "on",
|
||
honeypot: String(formData.get("company") || "").trim(),
|
||
idempotency_key: idempotencyKey,
|
||
turnstile_token: turnstileWidgets.suggestions.token,
|
||
},
|
||
"The suggestion could not be submitted.",
|
||
);
|
||
|
||
form.reset();
|
||
setFormStatus(
|
||
"Received. The loop is now in the private review queue.",
|
||
"success",
|
||
);
|
||
idempotencyKey = makeIdempotencyKey();
|
||
formStartedAt = performance.now();
|
||
resetTurnstile(turnstileWidgets.suggestions);
|
||
} catch (error) {
|
||
resetTurnstile(turnstileWidgets.suggestions);
|
||
setFormStatus(
|
||
error.message || "Something went wrong. Try again in a moment.",
|
||
"error",
|
||
);
|
||
} finally {
|
||
submitButton.disabled = !turnstileWidgets.suggestions.token;
|
||
submitButtonLabel.textContent = "Submit loop";
|
||
}
|
||
});
|
||
}
|
||
|
||
if (weeklyForm && weeklyButton && weeklyButtonLabel) {
|
||
weeklyForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
setWeeklyStatus("");
|
||
|
||
if (!weeklyForm.checkValidity()) {
|
||
weeklyForm.reportValidity();
|
||
setWeeklyStatus("Enter a valid email address.", "error");
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData(weeklyForm);
|
||
|
||
if (performance.now() - weeklyFormStartedAt < 800) {
|
||
setWeeklyStatus("Take a moment, then submit again.", "error");
|
||
return;
|
||
}
|
||
|
||
if (!formProtectionReady) {
|
||
setWeeklyStatus(
|
||
"Signups are temporarily unavailable. Refresh and try again.",
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!turnstileWidgets.weeklySignups.token) {
|
||
setWeeklyStatus(
|
||
turnstileWidgets.weeklySignups.pendingMessage,
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
|
||
weeklyButton.disabled = true;
|
||
weeklyButtonLabel.textContent = "Adding";
|
||
|
||
try {
|
||
await postProtectedForm(
|
||
"/weekly-signups",
|
||
{
|
||
payload: { email: String(formData.get("email")).trim() },
|
||
honeypot: String(
|
||
formData.get("newsletter_company") || "",
|
||
).trim(),
|
||
idempotency_key: weeklyIdempotencyKey,
|
||
turnstile_token: turnstileWidgets.weeklySignups.token,
|
||
},
|
||
"The signup could not be submitted.",
|
||
);
|
||
weeklyForm.reset();
|
||
setWeeklyStatus(
|
||
"You’re on the list. Watch your inbox for the best loops.",
|
||
"success",
|
||
);
|
||
weeklyIdempotencyKey = makeIdempotencyKey();
|
||
weeklyFormStartedAt = performance.now();
|
||
resetTurnstile(turnstileWidgets.weeklySignups);
|
||
} catch (error) {
|
||
resetTurnstile(turnstileWidgets.weeklySignups);
|
||
setWeeklyStatus(
|
||
error.message || "Something went wrong. Try again in a moment.",
|
||
"error",
|
||
);
|
||
} finally {
|
||
weeklyButton.disabled = !turnstileWidgets.weeklySignups.token;
|
||
weeklyButtonLabel.textContent = "Notify me weekly";
|
||
}
|
||
});
|
||
}
|
||
|
||
initializeFormProtection();
|