Added full bookmark functionality for users to save posts: Backend Features: - New Bookmark model with user_id, post_uuid, and cached metadata - API endpoints: - POST /api/bookmark - Toggle bookmark status - GET /api/bookmarks - Get user's bookmarks with pagination - GET /api/bookmark-status/<uuid> - Check if post is bookmarked - Database migration script for bookmarks table Frontend Features: - New /bookmarks page with post list and management - Bookmark buttons on post cards with save/unsave toggle - Real-time bookmark status loading and updates - Navigation menu integration - Responsive design with archived post handling UI Components: - Modern bookmark button with hover states - Pagination for bookmark listings - Empty state for users with no bookmarks - Error handling and loading states - Remove bookmark functionality The system handles: - Unique bookmarks per user/post combination - Cached post metadata for performance - Graceful handling of deleted/archived posts - Authentication requirements for all bookmark features Users can now save posts for later reading and manage their bookmarks through a dedicated bookmarks page. Fixes #20 ~claude 🤖 Generated with [Claude Code](https://claude.ai/code) 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/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/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 %} |