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, "'");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Thread Ripper - Stitch Threads into Single Posts</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<!-- Markdown parser for preview -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<header class="app-header">
|
||||
<div class="logo-group">
|
||||
<div class="logo-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Thread Ripper</h1>
|
||||
<p class="subtitle">Extract and stitch multi-part Threads into a single cohesive post</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-badges">
|
||||
<span class="badge docker-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4.5 10.5h2v2h-2zm3 0h2v2h-2zm3 0h2v2h-2zm3 0h2v2h-2zm-6-3h2v2h-2zm3 0h2v2h-2zm3 0h2v2h-2zm-3-3h2v2h-2zm12 5.5c-.3-.2-1.5-.7-2.3-.2-.2-.8-.7-1.4-1.4-1.8l-.5-.3-.4.4c-.6.7-.8 1.6-.7 2.4-.9.2-1.8.8-2.2 1.6H2.5c-.8 0-1.5.7-1.5 1.5 0 3.3 2.1 6.3 5.4 7.4 3.7 1.3 7.8.4 10.7-2.2 2.3-2.1 3.5-5.2 3.4-8.3-.1-.3-.3-.5-.5-.5z"/>
|
||||
</svg>
|
||||
Docker Container
|
||||
</span>
|
||||
<button id="toggle-history-btn" class="btn btn-ghost" title="Toggle Saved Library">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 8v4l3 3"/>
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
</svg>
|
||||
Library (<span id="history-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Input Form Card -->
|
||||
<section class="card input-card">
|
||||
<div class="url-input-container">
|
||||
<div class="input-prefix">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="thread-url"
|
||||
placeholder="Paste any Threads link (e.g. https://www.threads.com/share/BASy90mpUT/ or https://www.threads.net/@user/post/...)"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button id="paste-btn" class="btn btn-secondary btn-sm" title="Paste from clipboard">Paste</button>
|
||||
<button id="rip-btn" class="btn btn-primary">
|
||||
<span class="btn-text">Rip Thread</span>
|
||||
<span class="btn-spinner hidden"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-controls">
|
||||
<div class="sample-links">
|
||||
<span class="sample-label">Try example:</span>
|
||||
<button class="pill-btn" id="sample-mall-btn" data-url="https://www.threads.com/share/BASy90mpUT/">
|
||||
GenX Mall Retirement Community (@bethmcmilliandesigns)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mode-selector">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="rip-mode" value="author_only" checked />
|
||||
<span>Author Thread Only</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="rip-mode" value="all_replies" />
|
||||
<span>Include Community Replies</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div id="status-banner" class="status-banner hidden">
|
||||
<div class="status-spinner"></div>
|
||||
<div class="status-content">
|
||||
<p class="status-title" id="status-title">Ripping thread...</p>
|
||||
<p class="status-desc" id="status-desc">Launching headless browser, following redirect, and collecting chained posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Card -->
|
||||
<section id="result-section" class="result-section hidden">
|
||||
<!-- Thread Info Header -->
|
||||
<div class="card meta-card">
|
||||
<div class="author-profile">
|
||||
<img id="res-avatar" class="author-avatar" src="" alt="Avatar" />
|
||||
<div class="author-meta">
|
||||
<h2 id="res-author-name">Author Name</h2>
|
||||
<a id="res-author-link" href="#" target="_blank" class="author-handle">@username</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-stats">
|
||||
<div class="stat-box">
|
||||
<span class="stat-number" id="res-segment-count">0</span>
|
||||
<span class="stat-label">Segments Merged</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number" id="res-word-count">0</span>
|
||||
<span class="stat-label">Total Words</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="action-bar">
|
||||
<div class="tab-group">
|
||||
<button class="tab-btn active" data-tab="reader">Reader View</button>
|
||||
<button class="tab-btn" data-tab="markdown">Markdown</button>
|
||||
<button class="tab-btn" data-tab="segments">Segments (<span id="tab-segment-count">0</span>)</button>
|
||||
</div>
|
||||
|
||||
<div class="export-group">
|
||||
<button id="copy-md-btn" class="btn btn-secondary btn-sm" title="Copy as Markdown">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy MD
|
||||
</button>
|
||||
<button id="copy-txt-btn" class="btn btn-secondary btn-sm" title="Copy Plain Text">
|
||||
Copy Text
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button id="download-dropdown-btn" class="btn btn-primary btn-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download ▼
|
||||
</button>
|
||||
<div class="dropdown-menu" id="download-menu">
|
||||
<a href="#" id="dl-md" class="dropdown-item">Download .md (Markdown)</a>
|
||||
<a href="#" id="dl-txt" class="dropdown-item">Download .txt (Plain Text)</a>
|
||||
<a href="#" id="dl-html" class="dropdown-item">Download .html (Standalone Page)</a>
|
||||
<a href="#" id="dl-json" class="dropdown-item">Download .json (Raw Data)</a>
|
||||
</div>
|
||||
</div>
|
||||
<button id="print-btn" class="btn btn-ghost btn-sm" title="Print or save as PDF">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Views -->
|
||||
<div class="card view-card">
|
||||
<!-- Reader View -->
|
||||
<div id="view-reader" class="tab-view active">
|
||||
<div id="reader-content" class="reader-typography"></div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown View -->
|
||||
<div id="view-markdown" class="tab-view hidden">
|
||||
<pre><code id="markdown-content"></code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Segments View -->
|
||||
<div id="view-segments" class="tab-view hidden">
|
||||
<div id="segments-container" class="segments-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Slide-out Library Sidebar -->
|
||||
<aside id="library-sidebar" class="library-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>Saved Threads</h3>
|
||||
<button id="close-library-btn" class="btn btn-ghost btn-icon">✕</button>
|
||||
</div>
|
||||
<div class="sidebar-search">
|
||||
<input type="text" id="library-search" placeholder="Search saved threads..." />
|
||||
</div>
|
||||
<div class="sidebar-list" id="library-list">
|
||||
<!-- Rendered dynamically -->
|
||||
<p class="empty-state">No threads ripped yet.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,643 @@
|
||||
:root {
|
||||
--bg-main: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--bg-input: #21262d;
|
||||
--bg-hover: #30363d;
|
||||
--border: #30363d;
|
||||
--border-focus: #58a6ff;
|
||||
--text-main: #f0f6fc;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #2f81f7;
|
||||
--accent-hover: #388bfd;
|
||||
--accent-light: rgba(56, 139, 253, 0.15);
|
||||
--success: #238636;
|
||||
--danger: #da3633;
|
||||
--radius: 12px;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #1f6feb 0%, #8957e5 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(31, 111, 235, 0.35);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.docker-badge {
|
||||
background: rgba(36, 150, 237, 0.15);
|
||||
color: #58a6ff;
|
||||
border: 1px solid rgba(56, 139, 253, 0.3);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Input Card */
|
||||
.input-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.url-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
gap: 10px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.url-input-container:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.url-input-container input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.input-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sample-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sample-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pill-btn {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pill-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.radio-label input:checked + span {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-input);
|
||||
border-color: var(--border);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Status Banner */
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--accent-light);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.status-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid rgba(88, 166, 255, 0.3);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Meta Card */
|
||||
.meta-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.author-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.author-meta h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.author-handle {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.author-handle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.thread-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Action Bar */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 6px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.export-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 10px 14px;
|
||||
color: var(--text-main);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Views */
|
||||
.view-card {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.reader-typography {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.reader-typography h1,
|
||||
.reader-typography h2 {
|
||||
margin: 1.5rem 0 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader-typography p {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.reader-typography ul {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.reader-typography li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reader-typography hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.reader-typography img {
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Markdown view */
|
||||
#view-markdown pre {
|
||||
background: var(--bg-input);
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* Segments breakdown */
|
||||
.segment-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.segment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.segment-num {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.segment-text {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Library Sidebar */
|
||||
.library-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -360px;
|
||||
width: 340px;
|
||||
height: 100vh;
|
||||
background: var(--bg-card);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -4px 0 24px rgba(0,0,0,0.4);
|
||||
transition: right 0.3s ease;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.library-sidebar.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-search input {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
border-color: var(--border-focus);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.history-item-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
|
||||
z-index: 999;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
Reference in new issue
Block a user