Implements the parallel.md workstreams (Agents A-E) toward REFACTOR_GOAL.md. Phase 1 — app factory + blueprints + /api/v1: - app.py -> create_app() factory (no module-level app); entrypoints updated - routes/ (auth, pages, settings, admin, assets) + blueprints/api.py at /api/v1 - config.py / extensions.py / security.py extracted; services/ layer added - endpoint names preserved so template url_for() calls keep resolving (static check: all 27 template url_for endpoints are defined routes) Phase 2 — one pluggable filter system: - filter_pipeline/registry.py: @register_stage / @register_plugin + discover_modules - engine._init_stages() instantiates registered stages (no hardcoded dict); process_batch is AI-aware: only short-circuits to the AI-disabled path when a filterset's stages declare requires_ai, so offline filtersets run with AI off - BaseFilterPlugin gets a consumer (stages/plugins.py); Keyword/Quality re-enabled via filter_config.json plugins config - comment tree modes ported to stages/comment_filter.py + shared rules.py; wired into /api/v1/posts/<uuid> and /api/v1/comments/<uuid> via FilterEngine.filter_comments() (fails open) - offline quality_filter filterset exercises plugins+ranker without AI - legacy filter_lib / comment_lib / html_generation_lib / generate_html / active_html path deleted Phase 3 prep — pluggable fetchers + Postgres models: - Post / Comment SQLAlchemy models added to models.py - migrate_content_to_db.py backfill (idempotent by uuid, batched, --dry-run) - platforms/ fetcher registry (extension point) - live reads/writes still go through PostService (disk JSON); cutover deferred Phase 6 — test harness: - pytest.ini + tests/ (conftest with in-memory SQLite fixture, no Postgres; stubbed polling/filter singletons) - test_app_factory.py (route registration, no module-level app), test_api_contracts.py (posts/post_detail/comments/filters shape with monkeypatched post_service + get_filter_engine), test_filter_pipeline.py + test_plugin_contract.py (Flask-free; validated locally 12/12 incl. drop-in stage/plugin discovered with zero core edits) Other: .gitignore added (__pycache__, data/, secrets); pytest in requirements. Verification: py_compile clean across the project; the Flask-free filter-pipeline and plugin-contract tests pass locally. App-factory / API-contract tests need deps+docker to run; runtime flask routes / Auth0-repeated-create_app also gated on docker. Co-Authored-By: Claude <noreply@anthropic.com>
272 lines
7.9 KiB
HTML
272 lines
7.9 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Bookmarks - {{ APP_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
{% include '_nav.html' %}
|
|
|
|
<div style="max-width: 1200px; margin: 0 auto; padding: 24px;">
|
|
<div style="margin-bottom: 32px;">
|
|
<h1 style="color: var(--text-primary); margin-bottom: 8px;">📚 Your Bookmarks</h1>
|
|
<p style="color: var(--text-secondary); font-size: 1.1rem;">Posts you've saved for later reading</p>
|
|
</div>
|
|
|
|
<div id="bookmarks-container">
|
|
<div id="loading" style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
|
<div style="font-size: 1.2rem;">Loading your bookmarks...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div id="pagination" style="display: none; text-align: center; margin-top: 32px;">
|
|
<button id="prev-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">← Previous</button>
|
|
<span id="page-info" style="margin: 0 16px; color: var(--text-secondary);"></span>
|
|
<button id="next-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">Next →</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.bookmark-item {
|
|
background: var(--surface-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.bookmark-item:hover {
|
|
border-color: var(--primary-color);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.bookmark-item.archived {
|
|
opacity: 0.6;
|
|
border-style: dashed;
|
|
}
|
|
|
|
.bookmark-header {
|
|
display: flex;
|
|
justify-content: between;
|
|
align-items: flex-start;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.bookmark-title {
|
|
color: var(--text-primary);
|
|
font-size: 1.2rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
flex: 1;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.bookmark-title:hover {
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.bookmark-remove {
|
|
background: var(--error-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.bookmark-remove:hover {
|
|
background: var(--error-hover);
|
|
}
|
|
|
|
.bookmark-meta {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 8px;
|
|
font-size: 0.9rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.bookmark-meta span {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.bookmark-preview {
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.bookmark-date {
|
|
font-size: 0.85rem;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state h3 {
|
|
font-size: 1.3rem;
|
|
margin-bottom: 8px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.error-state {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: var(--error-color);
|
|
background: var(--error-bg);
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
let currentPage = 1;
|
|
let pagination = null;
|
|
|
|
async function loadBookmarks(page = 1) {
|
|
try {
|
|
document.getElementById('loading').style.display = 'block';
|
|
|
|
const response = await fetch(`/api/v1/bookmarks?page=${page}&per_page=20`);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to load bookmarks');
|
|
}
|
|
|
|
renderBookmarks(data.posts);
|
|
updatePagination(data.pagination);
|
|
currentPage = page;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading bookmarks:', error);
|
|
document.getElementById('bookmarks-container').innerHTML = `
|
|
<div class="error-state">
|
|
<h3>Error loading bookmarks</h3>
|
|
<p>${error.message}</p>
|
|
<button onclick="loadBookmarks()" style="margin-top: 12px; padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer;">Retry</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function renderBookmarks(posts) {
|
|
document.getElementById('loading').style.display = 'none';
|
|
|
|
const container = document.getElementById('bookmarks-container');
|
|
|
|
if (posts.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<h3>📚 No bookmarks yet</h3>
|
|
<p>Start exploring and bookmark posts you want to read later!</p>
|
|
<a href="/" style="display: inline-block; margin-top: 16px; padding: 12px 24px; background: var(--primary-color); color: white; text-decoration: none; border-radius: 8px;">Browse Posts</a>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = posts.map(post => `
|
|
<div class="bookmark-item ${post.archived ? 'archived' : ''}">
|
|
<div class="bookmark-header">
|
|
<a href="${post.url}" class="bookmark-title">${post.title}</a>
|
|
<button class="bookmark-remove" onclick="removeBookmark('${post.id}', this)">
|
|
🗑️ Remove
|
|
</button>
|
|
</div>
|
|
|
|
<div class="bookmark-meta">
|
|
<span>👤 ${post.author}</span>
|
|
<span>📍 ${post.source}</span>
|
|
<span>⭐ ${post.score}</span>
|
|
<span>💬 ${post.comments_count}</span>
|
|
${post.archived ? '<span style="color: var(--warning-color);">📦 Archived</span>' : ''}
|
|
</div>
|
|
|
|
<div class="bookmark-preview">${post.content_preview}</div>
|
|
|
|
<div class="bookmark-date">
|
|
Bookmarked on ${new Date(post.bookmarked_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function updatePagination(paginationData) {
|
|
pagination = paginationData;
|
|
const paginationEl = document.getElementById('pagination');
|
|
|
|
if (paginationData.total_pages <= 1) {
|
|
paginationEl.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
paginationEl.style.display = 'block';
|
|
|
|
document.getElementById('prev-btn').disabled = !paginationData.has_prev;
|
|
document.getElementById('next-btn').disabled = !paginationData.has_next;
|
|
document.getElementById('page-info').textContent = `Page ${paginationData.current_page} of ${paginationData.total_pages}`;
|
|
}
|
|
|
|
async function removeBookmark(postId, button) {
|
|
if (!confirm('Are you sure you want to remove this bookmark?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
button.disabled = true;
|
|
button.textContent = 'Removing...';
|
|
|
|
const response = await fetch('/api/v1/bookmark', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ post_uuid: postId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to remove bookmark');
|
|
}
|
|
|
|
// Reload bookmarks to reflect changes
|
|
loadBookmarks(currentPage);
|
|
|
|
} catch (error) {
|
|
console.error('Error removing bookmark:', error);
|
|
alert('Error removing bookmark: ' + error.message);
|
|
button.disabled = false;
|
|
button.textContent = '🗑️ Remove';
|
|
}
|
|
}
|
|
|
|
// Pagination event listeners
|
|
document.getElementById('prev-btn').addEventListener('click', () => {
|
|
if (pagination && pagination.has_prev) {
|
|
loadBookmarks(currentPage - 1);
|
|
}
|
|
});
|
|
|
|
document.getElementById('next-btn').addEventListener('click', () => {
|
|
if (pagination && pagination.has_next) {
|
|
loadBookmarks(currentPage + 1);
|
|
}
|
|
});
|
|
|
|
// Load bookmarks on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadBookmarks();
|
|
});
|
|
</script>
|
|
{% endblock %} |