Initial commit: Thread Ripper containerized application
This commit is contained in:
commit
a648a59c0f
16 files changed
+2213
No files matched your search
@@ -0,0 +1,329 @@
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user