Initial commit: Thread Ripper containerized application

This commit is contained in:
airent106 committed 2026-10-06 17:55:41 -06:00
commit a648a59c0f
16 files changed
+2213

No files matched your search

+329
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
});
+199
View File
@@ -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>
+643
View File
@@ -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;
}