Files
thread-ripper/thread_ripper/static/app.js
T

330 lines
10 KiB
JavaScript

document.addEventListener("DOMContentLoaded", () => {
const threadUrlInput = document.getElementById("thread-url");
const pasteBtn = document.getElementById("paste-btn");
const ripBtn = document.getElementById("rip-btn");
const sampleMallBtn = document.getElementById("sample-mall-btn");
const statusBanner = document.getElementById("status-banner");
const statusTitle = document.getElementById("status-title");
const statusDesc = document.getElementById("status-desc");
const resultSection = document.getElementById("result-section");
// Results elements
const resAvatar = document.getElementById("res-avatar");
const resAuthorName = document.getElementById("res-author-name");
const resAuthorLink = document.getElementById("res-author-link");
const resSegmentCount = document.getElementById("res-segment-count");
const resWordCount = document.getElementById("res-word-count");
const tabSegmentCount = document.getElementById("tab-segment-count");
const readerContent = document.getElementById("reader-content");
const markdownContent = document.getElementById("markdown-content");
const segmentsContainer = document.getElementById("segments-container");
// Export buttons
const copyMdBtn = document.getElementById("copy-md-btn");
const copyTxtBtn = document.getElementById("copy-txt-btn");
const downloadDropdownBtn = document.getElementById("download-dropdown-btn");
const downloadMenu = document.getElementById("download-menu");
const dlMd = document.getElementById("dl-md");
const dlTxt = document.getElementById("dl-txt");
const dlHtml = document.getElementById("dl-html");
const dlJson = document.getElementById("dl-json");
const printBtn = document.getElementById("print-btn");
// Library / History elements
const toggleHistoryBtn = document.getElementById("toggle-history-btn");
const librarySidebar = document.getElementById("library-sidebar");
const closeLibraryBtn = document.getElementById("close-library-btn");
const libraryList = document.getElementById("library-list");
const libraryCount = document.getElementById("history-count");
const librarySearch = document.getElementById("library-search");
// Toast
const toast = document.getElementById("toast");
let currentThread = null;
let allHistory = [];
// Initialize history
fetchHistory();
// Paste button
pasteBtn.addEventListener("click", async () => {
try {
const text = await navigator.clipboard.readText();
if (text) {
threadUrlInput.value = text.trim();
}
} catch (e) {
showToast("Clipboard access denied or unavailable", true);
}
});
// Example button
sampleMallBtn.addEventListener("click", () => {
threadUrlInput.value = sampleMallBtn.getAttribute("data-url");
triggerRip();
});
// Rip button
ripBtn.addEventListener("click", () => {
triggerRip();
});
threadUrlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
triggerRip();
}
});
async function triggerRip() {
const url = threadUrlInput.value.trim();
if (!url) {
showToast("Please paste a Threads URL", true);
return;
}
const mode = document.querySelector('input[name="rip-mode"]:checked').value;
setLoading(true);
statusTitle.textContent = "Connecting to Threads...";
statusDesc.textContent = "Launching headless browser and resolving URL...";
try {
const response = await fetch("/api/rip", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, mode, max_scrolls: 5 })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Failed to rip thread");
}
const data = await response.json();
currentThread = data;
renderThread(data);
fetchHistory();
showToast("Thread successfully ripped & saved!");
} catch (err) {
console.error(err);
showToast(`Error: ${err.message}`, true);
} finally {
setLoading(false);
}
}
function setLoading(isLoading) {
const btnText = ripBtn.querySelector(".btn-text");
const spinner = ripBtn.querySelector(".btn-spinner");
if (isLoading) {
ripBtn.disabled = true;
btnText.textContent = "Ripping...";
spinner.classList.remove("hidden");
statusBanner.classList.remove("hidden");
resultSection.classList.add("hidden");
} else {
ripBtn.disabled = false;
btnText.textContent = "Rip Thread";
spinner.classList.add("hidden");
statusBanner.classList.add("hidden");
}
}
function renderThread(thread) {
resultSection.classList.remove("hidden");
// Header info
resAuthorName.textContent = thread.author_name;
resAuthorLink.textContent = `@${thread.author_handle}`;
resAuthorLink.href = thread.canonical_url;
if (thread.author_avatar) {
resAvatar.src = thread.author_avatar;
resAvatar.classList.remove("hidden");
} else {
resAvatar.classList.add("hidden");
}
resSegmentCount.textContent = thread.segment_count;
resWordCount.textContent = thread.total_words;
tabSegmentCount.textContent = thread.segment_count;
// Render Markdown view
markdownContent.textContent = thread.stitched_markdown;
// Render Reader view (using marked.js parser)
if (window.marked) {
readerContent.innerHTML = marked.parse(thread.stitched_markdown);
} else {
readerContent.innerHTML = thread.stitched_html;
}
// Render Segments breakdown view
segmentsContainer.innerHTML = "";
thread.segments.forEach((seg, idx) => {
const segEl = document.createElement("div");
segEl.className = "segment-item";
segEl.innerHTML = `
<div class="segment-header">
<span class="segment-num">Segment #${idx + 1} ${seg.is_author ? "(Author)" : "(Reply)"}</span>
<span>${seg.author_handle ? "@" + seg.author_handle : ""}</span>
</div>
<div class="segment-text">${escapeHtml(seg.text)}</div>
`;
segmentsContainer.appendChild(segEl);
});
// Setup download links
dlMd.href = `/api/download/${thread.id}/md`;
dlTxt.href = `/api/download/${thread.id}/txt`;
dlHtml.href = `/api/download/${thread.id}/html`;
dlJson.href = `/api/download/${thread.id}/json`;
// Switch to reader view
switchTab("reader");
}
// Tabs switching
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const tabName = btn.getAttribute("data-tab");
switchTab(tabName);
});
});
function switchTab(tabName) {
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelector(`.tab-btn[data-tab="${tabName}"]`)?.classList.add("active");
document.querySelectorAll(".tab-view").forEach((v) => {
v.classList.add("hidden");
v.classList.remove("active");
});
const targetView = document.getElementById(`view-${tabName}`);
if (targetView) {
targetView.classList.remove("hidden");
targetView.classList.add("active");
}
}
// Copy buttons
copyMdBtn.addEventListener("click", () => {
if (!currentThread) return;
navigator.clipboard.writeText(currentThread.stitched_markdown);
showToast("Copied Markdown to clipboard!");
});
copyTxtBtn.addEventListener("click", () => {
if (!currentThread) return;
navigator.clipboard.writeText(currentThread.stitched_text);
showToast("Copied Plain Text to clipboard!");
});
// Download dropdown toggle
downloadDropdownBtn.addEventListener("click", (e) => {
e.stopPropagation();
downloadMenu.classList.toggle("show");
});
document.addEventListener("click", () => {
downloadMenu.classList.remove("show");
});
// Print button
printBtn.addEventListener("click", () => {
if (!currentThread) return;
const printWindow = window.open("", "_blank");
printWindow.document.write(currentThread.stitched_html);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 500);
});
// Library / History
toggleHistoryBtn.addEventListener("click", () => {
librarySidebar.classList.toggle("open");
});
closeLibraryBtn.addEventListener("click", () => {
librarySidebar.classList.remove("open");
});
async function fetchHistory() {
try {
const res = await fetch("/api/history");
if (res.ok) {
allHistory = await res.json();
libraryCount.textContent = allHistory.length;
renderHistoryList(allHistory);
}
} catch (e) {
console.warn("Could not load history", e);
}
}
function renderHistoryList(items) {
if (!items || items.length === 0) {
libraryList.innerHTML = '<p class="empty-state">No threads ripped yet.</p>';
return;
}
libraryList.innerHTML = "";
items.forEach((item) => {
const div = document.createElement("div");
div.className = "history-item";
div.innerHTML = `
<div class="history-item-title">${escapeHtml(item.title)}</div>
<div class="history-item-meta">
<span>@${escapeHtml(item.author_handle)} (${item.segment_count} posts)</span>
<span>${item.saved_at.split("T")[0] || ""}</span>
</div>
`;
div.addEventListener("click", async () => {
try {
const res = await fetch(`/api/history/${item.id}`);
if (res.ok) {
currentThread = await res.json();
renderThread(currentThread);
librarySidebar.classList.remove("open");
}
} catch (e) {
showToast("Failed to load thread", true);
}
});
libraryList.appendChild(div);
});
}
librarySearch.addEventListener("input", (e) => {
const q = e.target.value.toLowerCase();
const filtered = allHistory.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.author_handle.toLowerCase().includes(q)
);
renderHistoryList(filtered);
});
function showToast(msg, isError = false) {
toast.textContent = msg;
toast.style.background = isError ? "#da3633" : "#238636";
toast.classList.remove("hidden");
setTimeout(() => {
toast.classList.add("hidden");
}, 3500);
}
function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
});