Add themes, static assets, and logo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-11 17:38:19 -05:00
parent 5c00a99523
commit 62001d08a4
18 changed files with 2825 additions and 0 deletions

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

View File

@@ -0,0 +1,59 @@
<!-- Modern Card UI - Post Card Template -->
<template id="modern-card-template">
<article class="post-card" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="card-surface">
<!-- Header -->
<header class="card-header">
<div class="header-meta">
<span class="platform-badge platform-{{platform}}">{{platform|upper}}</span>
{% if source %}
<span class="card-source">{{source}}</span>
{% endif %}
</div>
<div class="vote-indicator">
<span class="vote-score">{{score}}</span>
<span class="vote-label">pts</span>
</div>
</header>
<!-- Title -->
<div class="card-title-section">
<h2 class="card-title">
<a href="{{post_url}}" class="title-link">{{title}}</a>
</h2>
</div>
<!-- Content Preview -->
{% if content %}
<div class="card-content-preview">
<p class="content-text">{{ truncate(content, 150) }}</p>
</div>
{% endif %}
<!-- Footer -->
<footer class="card-footer">
<div class="author-info">
<span class="author-name">{{author}}</span>
<span class="post-time">{{formatTimeAgo(timestamp)}}</span>
</div>
<div class="engagement-info">
<span class="reply-count">{{replies}} replies</span>
</div>
</footer>
<!-- Tags -->
{% if tags %}
<div class="card-tags">
{% for tag in tags[:3] if tag %}
<span class="tag-chip">{{tag}}</span>
{% endfor %}
{% if tags|length > 3 %}
<span class="tag-more">+{{tags|length - 3}} more</span>
{% endif %}
</div>
{% endif %}
</div>
</article>
</template>

View File

@@ -0,0 +1,41 @@
<!-- Modern Card UI - Comment Template -->
<template id="modern-comment-template">
<div class="comment-card" data-comment-id="{{uuid}}" style="margin-left: {{depth * 24}}px">
<div class="comment-surface">
<!-- Comment Header -->
<header class="comment-header">
<div class="comment-meta">
<span class="comment-author">{{author}}</span>
<span class="comment-time">{{formatTimeAgo(timestamp)}}</span>
{% if score != 0 %}
<div class="comment-score">
<span class="score-number">{{score}}</span>
<span class="score-label">pts</span>
</div>
{% endif %}
</div>
</header>
<!-- Comment Content -->
<div class="comment-body">
<div class="comment-text">
{{ renderMarkdown(content)|safe }}
</div>
{% if children_section %}
<!-- Nested replies section -->
<div class="comment-replies">
{{children_section|safe}}
</div>
{% endif %}
</div>
<!-- Comment Footer (for actions) -->
<footer class="comment-footer">
<div class="depth-indicator" data-depth="{{depth}}">
<span class="depth-label">Level {{depth + 1}}</span>
</div>
</footer>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<!-- Modern Card UI - Post Detail Template -->
<template id="modern-detail-template">
<article class="post-detail" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="detail-container">
<!-- Header Card -->
<header class="detail-header">
<div class="header-meta-card">
<div class="meta-row">
<span class="platform-badge platform-{{platform}}">{{platform|upper}}</span>
{% if source %}
<span class="detail-source">in {{source}}</span>
{% endif %}
</div>
<div class="headline-section">
<h1 class="detail-title">{{title}}</h1>
<div class="byline">
<span class="author-link">by {{author}}</span>
<span class="publication-time">{{formatDateTime(timestamp)}}</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-number">{{score}}</span>
<span class="stat-label">points</span>
</div>
<div class="stat-item">
<span class="stat-number">{{replies}}</span>
<span class="stat-label">comments</span>
</div>
</div>
{% if tags %}
<div class="detail-tags">
{% for tag in tags if tag %}
<span class="tag-pill">{{tag}}</span>
{% endfor %}
</div>
{% endif %}
</div>
</header>
<!-- Article Body -->
{% if content %}
<section class="article-body">
<div class="article-content">
{{ renderMarkdown(content)|safe }}
</div>
</section>
{% endif %}
<!-- Action Row -->
<div class="article-actions">
<a href="{{url}}" target="_blank" class="action-button primary">
View Original
</a>
</div>
<!-- Comments Section -->
{% if comments_section %}
<section class="comments-section">
<h2 class="comments-header">Comments ({{replies}})</h2>
{{comments_section|safe}}
</section>
{% endif %}
</div>
</article>
</template>

View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{% for css_path in theme.css_dependencies %}
<link rel="stylesheet" href="{{ css_path }}">
{% endfor %}
<style>
/* Enhanced Navigation Styles */
.nav-top {
background: var(--surface-color);
border-bottom: 1px solid var(--divider-color);
padding: 0.5rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-top-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-primary);
}
.nav-logo {
width: 32px;
height: 32px;
border-radius: 6px;
}
.nav-brand-text {
font-size: 1.25rem;
font-weight: 700;
}
.brand-balance {
color: var(--primary-color);
}
.brand-board {
color: var(--text-primary);
}
.nav-user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.nav-username {
font-weight: 500;
color: var(--text-primary);
}
.hamburger-menu {
position: relative;
}
.hamburger-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 3px;
transition: background-color 0.2s;
}
.hamburger-toggle:hover {
background: var(--hover-overlay);
}
.hamburger-line {
width: 20px;
height: 2px;
background: var(--text-primary);
transition: all 0.3s;
}
.hamburger-dropdown {
position: absolute;
top: 100%;
right: 0;
background: var(--surface-color);
border: 1px solid var(--divider-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
z-index: 1000;
display: none;
}
.hamburger-dropdown.show {
display: block;
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--text-primary);
text-decoration: none;
transition: background-color 0.2s;
border-bottom: 1px solid var(--divider-color);
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--hover-overlay);
}
.dropdown-divider {
height: 1px;
background: var(--divider-color);
margin: 0.25rem 0;
}
.nav-login-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-nav-login, .btn-nav-signup {
padding: 0.375rem 0.75rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-nav-login {
background: var(--surface-color);
color: var(--text-primary);
border: 1px solid var(--divider-color);
}
.btn-nav-login:hover {
background: var(--hover-overlay);
}
.btn-nav-signup {
background: var(--primary-color);
color: white;
}
.btn-nav-signup:hover {
background: var(--primary-hover);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.nav-username {
display: none;
}
}
</style>
</head>
<body>
<!-- Enhanced Top Navigation -->
<nav class="nav-top">
<div class="nav-top-container">
<a href="/" class="nav-brand">
<img src="/logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
</a>
<div class="nav-user-section">
<!-- Logged in user state -->
<div class="nav-user-info" style="display: none;">
<div class="nav-avatar">JD</div>
<span class="nav-username">johndoe</span>
<div class="hamburger-menu">
<button class="hamburger-toggle" onclick="toggleDropdown()">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</button>
<div class="hamburger-dropdown" id="userDropdown">
<a href="/settings" class="dropdown-item">
⚙️ Settings
</a>
<a href="/settings/profile" class="dropdown-item">
👤 Profile
</a>
<a href="/settings/communities" class="dropdown-item">
🌐 Communities
</a>
<a href="/settings/filters" class="dropdown-item">
🎛️ Filters
</a>
<div class="dropdown-divider"></div>
<a href="/admin" class="dropdown-item" style="display: none;">
🛠️ Admin
</a>
<div class="dropdown-divider" style="display: none;"></div>
<a href="/logout" class="dropdown-item">
🚪 Sign Out
</a>
</div>
</div>
</div>
<!-- Logged out state -->
<div class="nav-login-prompt">
<a href="/login" class="btn-nav-login">Log In</a>
<a href="/signup" class="btn-nav-signup">Sign Up</a>
</div>
</div>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon"></span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{{ filterset_name|title or 'All Posts' }}</h1>
<p class="post-count">{{ posts|length }} posts</p>
</header>
<div id="posts-container">
{% for post in posts %}
{{ post|safe }}
{% endfor %}
</div>
</div>
</main>
</div>
{% for js_path in theme.js_dependencies %}
<script src="{{ js_path }}"></script>
{% endfor %}
<script>
// Dropdown functionality
function toggleDropdown() {
const dropdown = document.getElementById('userDropdown');
dropdown.classList.toggle('show');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('userDropdown');
const toggle = document.querySelector('.hamburger-toggle');
if (!toggle.contains(event.target) && !dropdown.contains(event.target)) {
dropdown.classList.remove('show');
}
});
// Check user authentication state (this would be dynamic in a real app)
function checkAuthState() {
// This would normally check with the server
// For now, we'll show the logged out state
document.querySelector('.nav-user-info').style.display = 'none';
document.querySelector('.nav-login-prompt').style.display = 'flex';
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', checkAuthState);
</script>
</body>
</html>

View File

@@ -0,0 +1,121 @@
// Modern Card UI Theme Interactions
(function() {
'use strict';
// Enhanced hover effects
function initializeCardHoverEffects() {
const cards = document.querySelectorAll('.card-surface, .list-card, .comment-surface');
cards.forEach(card => {
card.addEventListener('mouseenter', function() {
// Subtle scale effect on hover
this.style.transform = 'translateY(-2px)';
this.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.15)';
});
card.addEventListener('mouseleave', function() {
// Reset transform
this.style.transform = '';
this.style.boxShadow = '';
});
});
}
// Lazy loading for performance
function initializeLazyLoading() {
if ('IntersectionObserver' in window) {
const options = {
root: null,
rootMargin: '50px',
threshold: 0.1
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Add visible class for animations
entry.target.classList.add('visible');
// Unobserve after animation
observer.unobserve(entry.target);
}
});
}, options);
// Observe all cards and comments
document.querySelectorAll('.post-card, .comment-card').forEach(card => {
observer.observe(card);
});
}
}
// Improved comment thread visibility
function initializeCommentThreading() {
const toggleButtons = document.querySelectorAll('.comment-toggle');
toggleButtons.forEach(button => {
button.addEventListener('click', function() {
const comment = this.closest('.comment-card');
const replies = comment.querySelector('.comment-replies');
if (replies) {
replies.classList.toggle('collapsed');
this.textContent = replies.classList.contains('collapsed') ? '+' : '-';
}
});
});
}
// Add CSS classes for JavaScript-enhanced features
function initializeThemeFeatures() {
document.documentElement.classList.add('js-enabled');
// Add platform-specific classes to body
const platformElements = document.querySelectorAll('[data-platform]');
const platforms = new Set();
platformElements.forEach(el => {
platforms.add(el.dataset.platform);
});
platforms.forEach(platform => {
document.body.classList.add(`has-${platform}`);
});
}
// Keyboard navigation for accessibility
function initializeKeyboardNavigation() {
const cards = document.querySelectorAll('.post-card, .comment-card');
cards.forEach(card => {
card.setAttribute('tabindex', '0');
card.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const link = this.querySelector('a');
if (link) {
link.click();
}
}
});
});
}
// Initialize all features when DOM is ready
function initializeTheme() {
initializeThemeFeatures();
initializeCardHoverEffects();
initializeLazyLoading();
initializeCommentThreading();
initializeKeyboardNavigation();
}
// Run initialization after DOM load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTheme);
} else {
initializeTheme();
}
})();

View File

@@ -0,0 +1,42 @@
<!-- Modern Card UI - Post List Template -->
<template id="modern-list-template">
<div class="post-list-item" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="list-card">
<!-- Platform indicator -->
<div class="list-platform">
<span class="platform-badge medium platform-{{platform}}">{{platform[:1]|upper}}</span>
</div>
<!-- Main content -->
<div class="list-content">
<div class="list-vote-section">
<div class="vote-display">
<span class="vote-number">{{score}}</span>
</div>
</div>
<div class="list-meta">
<h3 class="list-title">
<a href="{{post_url}}" class="title-link">{{title}}</a>
</h3>
<div class="list-details">
<div class="list-attribution">
{% if source %}
<span class="list-source">{{source}}</span>
<span class="separator"></span>
{% endif %}
<span class="list-author">{{author}}</span>
<span class="separator"></span>
<span class="list-time">{{formatTimeAgo(timestamp)}}</span>
</div>
<div class="list-engagement">
<span class="replies-indicator">{{replies}} replies</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,936 @@
/* BalanceBoard Theme Styles */
:root {
/* BalanceBoard Color Palette */
--primary-color: #4DB6AC;
--primary-hover: #26A69A;
--primary-dark: #1B3A52;
--accent-color: #4DB6AC;
--surface-color: #FFFFFF;
--background-color: #F5F5F5;
--surface-elevation-1: rgba(0, 0, 0, 0.05);
--surface-elevation-2: rgba(0, 0, 0, 0.10);
--surface-elevation-3: rgba(0, 0, 0, 0.15);
--text-primary: #1B3A52;
--text-secondary: #757575;
--text-accent: #4DB6AC;
--border-color: rgba(0, 0, 0, 0.12);
--divider-color: rgba(0, 0, 0, 0.08);
--hover-overlay: rgba(77, 182, 172, 0.08);
--active-overlay: rgba(77, 182, 172, 0.16);
}
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
margin: 0;
padding: 0;
}
/* BalanceBoard Navigation */
.balanceboard-nav {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
box-shadow: 0 2px 8px var(--surface-elevation-2);
position: sticky;
top: 0;
z-index: 1000;
border-bottom: 3px solid var(--primary-color);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 24px;
}
.nav-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: white;
}
.nav-logo {
width: 48px;
height: 48px;
border-radius: 50%;
background: white;
padding: 4px;
transition: transform 0.2s ease;
}
.nav-brand:hover .nav-logo {
transform: scale(1.05);
}
.nav-brand-text {
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.5px;
}
.nav-brand-text .brand-balance {
color: var(--primary-color);
font-weight: 400;
}
.nav-brand-text .brand-board {
color: white;
font-weight: 600;
}
.nav-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
margin-top: -4px;
}
/* Main Layout */
.app-layout {
display: flex;
max-width: 1400px;
margin: 0 auto;
gap: 24px;
padding: 24px;
min-height: calc(100vh - 80px);
}
/* Sidebar */
.sidebar {
width: 280px;
flex-shrink: 0;
position: sticky;
top: 88px;
height: fit-content;
max-height: calc(100vh - 104px);
overflow-y: auto;
}
.sidebar-section {
background: var(--surface-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border: 1px solid var(--border-color);
}
.sidebar-section h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 12px;
font-weight: 600;
}
/* User Card */
.user-card {
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
border-radius: 12px;
padding: 20px;
color: white;
text-align: center;
border: 2px solid var(--primary-color);
}
.user-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--primary-color);
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 600;
color: white;
}
.user-name {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 4px;
}
.user-karma {
font-size: 0.85rem;
opacity: 0.8;
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
}
.karma-item {
display: flex;
align-items: center;
gap: 4px;
}
.login-prompt {
text-align: center;
}
.login-prompt p {
margin-bottom: 16px;
font-size: 0.95rem;
opacity: 0.9;
}
.btn-login {
display: block;
width: 100%;
padding: 10px 16px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
text-align: center;
}
.btn-login:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
}
.btn-signup {
display: block;
width: 100%;
padding: 10px 16px;
background: transparent;
color: white;
border: 2px solid var(--primary-color);
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
text-align: center;
margin-top: 8px;
}
.btn-signup:hover {
background: rgba(77, 182, 172, 0.1);
border-color: var(--primary-hover);
}
/* Navigation Menu */
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
}
.nav-menu li {
margin-bottom: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
font-size: 0.95rem;
}
.nav-item:hover {
background: var(--hover-overlay);
color: var(--primary-color);
}
.nav-item.active {
background: var(--primary-color);
color: white;
font-weight: 600;
}
.nav-icon {
font-size: 1.25rem;
width: 20px;
text-align: center;
}
/* Filter Tags */
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-tag {
padding: 6px 12px;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 16px;
font-size: 0.85rem;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.filter-tag:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.filter-tag.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
/* Main Content Area */
.main-content {
flex: 1;
min-width: 0;
}
.container {
max-width: 100%;
}
/* Platform Colors */
.platform-reddit { background: linear-gradient(135deg, #FF4500, #FF6B35); color: white; }
.platform-hackernews { background: linear-gradient(135deg, #FF6600, #FF8533); color: white; }
.platform-lobsters { background: linear-gradient(135deg, #8B5A3C, #A0695A); color: white; }
/* Page Header */
.container > header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
border-left: 4px solid var(--primary-color);
}
header h1 {
font-size: 2rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
header .post-count {
color: var(--text-secondary);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
header .post-count::before {
content: "•";
color: var(--primary-color);
font-size: 1.5rem;
line-height: 1;
}
/* Post Cards */
.post-card {
margin-bottom: 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-surface {
background: var(--surface-color);
border-radius: 12px;
box-shadow: 0 2px 4px var(--surface-elevation-1);
padding: 24px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.card-surface:hover {
box-shadow: 0 4px 12px var(--surface-elevation-2);
transform: translateY(-2px);
background: var(--hover-overlay);
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-meta {
display: flex;
gap: 12px;
align-items: center;
}
.platform-badge {
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.vote-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.vote-score {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-accent);
}
/* Card Title */
.card-title {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
margin-bottom: 12px;
}
.title-link {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.title-link:hover {
color: var(--primary-color);
}
/* Content Preview */
.card-content-preview {
margin-bottom: 16px;
}
.content-text {
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
/* Card Footer */
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.author-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.author-name {
font-weight: 500;
color: var(--text-primary);
}
.engagement-info {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Tags */
.card-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-chip {
background: var(--primary-color);
color: white;
padding: 4px 8px;
border-radius: 16px;
font-size: 0.8rem;
font-weight: 500;
}
/* List View */
.post-list-item {
margin-bottom: 8px;
}
.list-card {
display: flex;
align-items: center;
background: var(--surface-color);
border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.list-card:hover {
background: var(--hover-overlay);
box-shadow: 0 2px 8px var(--surface-elevation-1);
}
.list-content {
display: flex;
align-items: center;
flex: 1;
gap: 16px;
}
.list-vote-section {
min-width: 60px;
text-align: center;
}
.vote-number {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-accent);
}
.list-meta {
flex: 1;
}
.list-title {
font-size: 1rem;
font-weight: 500;
margin-bottom: 4px;
}
.list-details {
display: flex;
justify-content: space-between;
align-items: center;
}
.list-attribution {
font-size: 0.85rem;
color: var(--text-secondary);
display: flex;
gap: 6px;
align-items: center;
}
.separator {
color: var(--divider-color);
}
.list-engagement {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* Detailed View */
.post-detail {
background: var(--surface-color);
border-radius: 16px;
box-shadow: 0 4px 12px var(--surface-elevation-1);
margin-bottom: 24px;
}
.detail-container {
padding: 32px;
}
.detail-header {
margin-bottom: 32px;
}
.header-meta-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.meta-row {
display: flex;
gap: 12px;
align-items: center;
}
.detail-source {
font-size: 1rem;
color: var(--text-secondary);
}
.headline-section {
margin-bottom: 24px;
}
.detail-title {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
margin-bottom: 16px;
}
.byline {
display: flex;
gap: 16px;
font-size: 1rem;
color: var(--text-secondary);
}
.author-link {
font-weight: 500;
color: var(--primary-color);
}
.stats-row {
display: flex;
gap: 32px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-number {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
text-transform: lowercase;
}
.detail-tags {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.tag-pill {
background: var(--primary-color);
color: white;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
/* Article Body */
.article-body {
margin-bottom: 32px;
padding: 24px 0;
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
.article-content {
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1.8;
}
.article-content p {
margin-bottom: 16px;
}
.article-content strong {
font-weight: 600;
color: var(--text-primary);
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px var(--surface-elevation-2);
margin: 16px 0;
display: block;
}
.article-content a {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s ease;
}
.article-content a:hover {
border-bottom-color: var(--primary-color);
}
.article-content em {
font-style: italic;
color: var(--text-secondary);
}
/* Action Buttons */
.article-actions {
margin-bottom: 32px;
display: flex;
gap: 16px;
}
.action-button {
display: inline-flex;
align-items: center;
padding: 12px 24px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.2s ease;
}
.action-button.primary {
background: var(--primary-color);
color: white;
border: none;
}
.action-button.primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
}
/* Comments Section */
.comments-section {
border-top: 1px solid var(--divider-color);
padding-top: 24px;
}
.comments-header {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 24px;
}
/* Comments */
.comment-card {
margin-bottom: 16px;
transition: margin-left 0.2s ease;
}
.comment-surface {
background: var(--surface-color);
border-radius: 8px;
border: 1px solid var(--border-color);
padding: 16px;
box-shadow: 0 1px 3px var(--surface-elevation-1);
}
.comment-header {
margin-bottom: 8px;
}
.comment-meta {
display: flex;
gap: 12px;
align-items: center;
font-size: 0.9rem;
}
.comment-author {
font-weight: 500;
color: var(--text-primary);
}
.comment-time {
color: var(--text-secondary);
}
.comment-score {
display: flex;
align-items: center;
gap: 4px;
}
.score-number {
font-weight: 600;
color: var(--text-secondary);
}
.comment-body {
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 8px;
}
.comment-text {
margin-bottom: 12px;
}
.comment-text p {
margin-bottom: 8px;
}
.comment-text img {
max-width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 6px var(--surface-elevation-1);
margin: 12px 0;
display: block;
}
.comment-text a {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s ease;
}
.comment-text a:hover {
border-bottom-color: var(--primary-color);
}
.comment-text strong {
font-weight: 600;
color: var(--text-primary);
}
.comment-text em {
font-style: italic;
}
.comment-replies {
border-left: 3px solid var(--divider-color);
margin-left: 16px;
padding-left: 16px;
}
.comment-footer {
font-size: 0.8rem;
}
.depth-indicator {
color: var(--text-secondary);
}
/* Responsive Design */
@media (max-width: 1024px) {
.app-layout {
flex-direction: column;
padding: 16px;
}
.sidebar {
width: 100%;
position: static;
max-height: none;
order: -1;
}
.sidebar-section {
margin-bottom: 12px;
}
.nav-container {
padding: 12px 16px;
}
.nav-logo {
width: 40px;
height: 40px;
}
.nav-brand-text {
font-size: 1.25rem;
}
}
@media (max-width: 768px) {
.app-layout {
padding: 12px;
gap: 16px;
}
.container {
padding: 0;
}
.card-surface {
padding: 16px;
}
.detail-container {
padding: 16px;
}
.detail-title {
font-size: 2rem;
}
.stats-row {
flex-direction: row;
gap: 24px;
}
.list-card {
padding: 8px 12px;
}
.list-content {
gap: 8px;
}
.nav-subtitle {
display: none;
}
}
/* Sidebar Responsive */
@media (max-width: 1024px) {
.app-layout {
flex-direction: column;
}
.sidebar {
width: 100%;
position: static;
max-height: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.sidebar-section {
margin-bottom: 0;
}
}
@media (max-width: 640px) {
.sidebar {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,67 @@
{
"template_id": "modern-card-ui-theme",
"template_path": "./themes/modern-card-ui",
"template_type": "card",
"data_schema": "../../schemas/post_schema.json",
"required_fields": [
"platform",
"id",
"title",
"author",
"timestamp",
"score",
"replies",
"url"
],
"optional_fields": [
"content",
"source",
"tags",
"meta"
],
"css_dependencies": [
"./themes/modern-card-ui/styles.css"
],
"js_dependencies": [
"./themes/modern-card-ui/interactions.js"
],
"templates": {
"card": "./themes/modern-card-ui/card-template.html",
"list": "./themes/modern-card-ui/list-template.html",
"detail": "./themes/modern-card-ui/detail-template.html",
"comment": "./themes/modern-card-ui/comment-template.html"
},
"render_options": {
"container_selector": "#posts-container",
"batch_size": 20,
"lazy_load": true,
"animate": true,
"hover_effects": true,
"card_elevation": true
},
"filters": {
"platform": true,
"date_range": true,
"score_threshold": true,
"source": true
},
"sorting": {
"default_field": "timestamp",
"default_order": "desc",
"available_fields": [
"timestamp",
"score",
"replies",
"title"
]
},
"color_scheme": {
"primary": "#1976D2",
"secondary": "#FFFFFF",
"accent": "#FF5722",
"background": "#FAFAFA",
"surface": "#FFFFFF",
"text_primary": "#212121",
"text_secondary": "#757575"
}
}

120
themes/template_prompt.txt Normal file
View File

@@ -0,0 +1,120 @@
# Template Creation Prompt for AI
This document describes the data structures, helper functions, and conventions an AI needs to create or modify HTML templates for this social media archive system.
## Data Structures Available
### Post Data (when rendering posts)
- **Available in all post templates (card, list, detail):**
- platform: string (e.g., "reddit", "hackernews")
- id: string (unique post identifier)
- title: string
- author: string
- timestamp: integer (unix timestamp)
- score: integer (up/down vote score)
- replies: integer (number of comments)
- url: string (original post URL)
- content: string (optional post body text)
- source: string (optional subreddit/community)
- tags: array of strings (optional tags/flair)
- meta: object (optional platform-specific metadata)
- comments: array (optional nested comment tree - only in detail templates)
- post_url: string (generated: "{uuid}.html" - for local linking to detail pages)
### Comment Data (when rendering comments)
- **Available in comment templates:**
- uuid: string (unique comment identifier)
- id: string (platform-specific identifier)
- author: string (comment author username)
- content: string (comment text)
- timestamp: integer (unix timestamp)
- score: integer (comment score)
- platform: string
- depth: integer (nesting level)
- children: array (nested replies)
- children_section: string (pre-rendered HTML of nested children)
## Template Engine: Jinja2
Templates use Jinja2 syntax (`{{ }}` for variables, `{% %}` for control flow).
### Important Filters:
- `|safe`: Mark content as safe HTML (for already-escaped content)
- Example: `{{ renderMarkdown(content)|safe }}`
### Available Control Structures:
- `{% if variable %}...{% endif %}`
- `{% for item in array %}...{% endfor %}`
- `{% set variable = value %}` (create local variables)
## Helper Functions Available
Call these in templates using `{{ function(arg) }}`:
### Time/Date Formatting:
- `formatTime(timestamp)` -> "HH:MM"
- `formatTimeAgo(timestamp)` -> "2 hours ago"
- `formatDateTime(timestamp)` -> "January 15, 2024 at 14:30"
### Text Processing:
- `truncate(text, max_length)` -> truncated string with "..."
- `escapeHtml(text)` -> HTML-escaped version
### Content Rendering:
- `renderMarkdown(text)` -> Basic HTML from markdown (returns already-escaped HTML)
## Template Types
### Card Template (for index/listing pages)
- Used for summary view of posts
- Links should use `post_url` to point to local detail pages
- Keep concise - truncated content, basic info
### List Template (compact listing)
- Even more compact than cards
- Vote scores, basic metadata, title link
### Detail Template (full post view)
- Full content, meta information
- Source link uses `url` (external)
- Must include `{{comments_section|safe}}` for rendered comments
### Comment Template (nested comments)
- Recursive rendering with depth styling
- Children rendered as flattened HTML in `children_section`
## Convenience Data Added by System
In `generate_html.py`, `post_url` is added to each post before rendering: `{post['uuid']}.html`
This allows templates to link to local detail pages instead of external Reddit.
## CSS Classes Convention
Templates use semantic CSS classes:
- Post cards: `.post-card`, `.post-header`, `.post-meta`, etc.
- Comments: `.comment`, `.comment-header`, `.comment-body`, etc.
- Platform: `.platform-{platform}` for platform-specific styling
## Examples
### Conditional Rendering:
```
{% if content %}
<p class="content">{{ renderMarkdown(content)|safe }}</p>
{% endif %}
```
### Looping Tags:
```
{% for tag in tags if tag %}
<span class="tag">{{ tag }}</span>
{% endfor %}
```
### Styling by Depth (comments):
```
<div class="comment" style="margin-left: {{depth * 20}}px">
```
When creating new templates, follow these patterns and use the available data and helper functions appropriately.

View File

@@ -0,0 +1,42 @@
<!-- Card Template - Jinja2 template -->
<template id="post-card-template">
<article class="post-card" data-post-id="{{id}}" data-platform="{{platform}}">
<header class="post-header">
<div class="post-meta">
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
{% if source %}<span class="post-source">{{source}}</span>{% endif %}
</div>
<h2 class="post-title">
<a href="{{post_url}}" target="_blank" rel="noopener">{{title}}</a>
</h2>
</header>
<div class="post-info">
<span class="post-author">by {{author}}</span>
<time class="post-time" datetime="{{timestamp}}">{{formatTime(timestamp)}}</time>
</div>
<div class="post-content">
{% if content %}<p class="post-excerpt">{{ renderMarkdown(content)|safe }}</p>{% endif %}
</div>
<footer class="post-footer">
<div class="post-stats">
<span class="stat-score" title="Score">
<i class="icon-score"></i> {{score}}
</span>
<span class="stat-replies" title="Replies">
<i class="icon-replies">💬</i> {{replies}}
</span>
</div>
{% if tags %}
<div class="post-tags">
{% for tag in tags if tag %}
<span class="tag">{{tag}}</span>
{% endfor %}
</div>
{% endif %}
</footer>
</article>
</template>

View File

@@ -0,0 +1,21 @@
<!-- Comment Template - Nested comment rendering with unlimited depth -->
<template id="comment-template">
<div class="comment" data-comment-uuid="{{uuid}}" data-depth="{{depth}}" style="margin-left: {{depth * 20}}px">
<div class="comment-header">
<span class="comment-author">{{author}}</span>
<time class="comment-time" datetime="{{timestamp}}">{{formatTimeAgo(timestamp)}}</time>
<span class="comment-score" title="Score">↑ {{score}}</span>
</div>
<div class="comment-body">
<p class="comment-content">{{renderMarkdown(content)|safe}}</p>
</div>
<div class="comment-footer">
<span class="comment-depth-indicator">Depth: {{depth}}</span>
</div>
<!-- Placeholder for nested children -->
{{children_section|safe}}
</div>
</template>

View File

@@ -0,0 +1,52 @@
<!-- Detail Template - Full post view -->
<template id="post-detail-template">
<article class="post-detail" data-post-id="{{id}}" data-platform="{{platform}}">
<header class="detail-header">
<div class="breadcrumb">
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
<span class="separator">/</span>
{% if source %}<span class="source-link">{{source}}</span>{% endif %}
</div>
<h1 class="detail-title">{{title}}</h1>
<div class="detail-meta">
<div class="author-info">
<span class="author-name">{{author}}</span>
<time class="post-time" datetime="{{timestamp}}">{{formatDateTime(timestamp)}}</time>
</div>
<div class="post-stats">
<span class="stat-item">
<i class="icon-score"></i> {{score}} points
</span>
<span class="stat-item">
<i class="icon-replies">💬</i> {{replies}} comments
</span>
</div>
</div>
</header>
{% if content %}
<div class="detail-content">
{{ renderMarkdown(content)|safe }}
</div>
{% endif %}
{% if tags %}
<div class="detail-tags">
{% for tag in tags if tag %}
<span class="tag">{{tag}}</span>
{% endfor %}
</div>
{% endif %}
{{comments_section|safe}}
<footer class="detail-footer">
<a href="{{url}}" target="_blank" rel="noopener" class="source-link-btn">
View on {{platform}}
</a>
</footer>
</article>
</template>

View File

@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{% for css_path in theme.css_dependencies %}
<link rel="stylesheet" href="{{ css_path }}">
{% endfor %}
<style>
/* Enhanced Navigation Styles */
.nav-top {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.5rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-top-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-primary);
}
.nav-logo {
width: 32px;
height: 32px;
border-radius: 6px;
}
.nav-brand-text {
font-size: 1.25rem;
font-weight: 700;
}
.brand-balance {
color: var(--accent);
}
.brand-board {
color: var(--text-primary);
}
.nav-user-section {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.nav-username {
font-weight: 500;
color: var(--text-primary);
}
.hamburger-menu {
position: relative;
}
.hamburger-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 3px;
transition: background-color 0.2s;
}
.hamburger-toggle:hover {
background: var(--surface-hover);
}
.hamburger-line {
width: 20px;
height: 2px;
background: var(--text-primary);
transition: all 0.3s;
}
.hamburger-dropdown {
position: absolute;
top: 100%;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
z-index: 1000;
display: none;
}
.hamburger-dropdown.show {
display: block;
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--text-primary);
text-decoration: none;
transition: background-color 0.2s;
border-bottom: 1px solid var(--border);
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--surface-hover);
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: 0.25rem 0;
}
.nav-login-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-nav-login, .btn-nav-signup {
padding: 0.375rem 0.75rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-nav-login {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-nav-login:hover {
background: var(--surface-hover);
}
.btn-nav-signup {
background: var(--accent);
color: white;
}
.btn-nav-signup:hover {
background: var(--accent-hover);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.nav-username {
display: none;
}
}
</style>
</head>
<body>
<!-- Enhanced Top Navigation -->
<nav class="nav-top">
<div class="nav-top-container">
<a href="/" class="nav-brand">
<img src="/logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
</a>
<div class="nav-user-section">
<!-- Logged in user state -->
<div class="nav-user-info" style="display: none;">
<div class="nav-avatar">JD</div>
<span class="nav-username">johndoe</span>
<div class="hamburger-menu">
<button class="hamburger-toggle" onclick="toggleDropdown()">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</button>
<div class="hamburger-dropdown" id="userDropdown">
<a href="/settings" class="dropdown-item">
⚙️ Settings
</a>
<a href="/settings/profile" class="dropdown-item">
👤 Profile
</a>
<a href="/settings/communities" class="dropdown-item">
🌐 Communities
</a>
<a href="/settings/filters" class="dropdown-item">
🎛️ Filters
</a>
<div class="dropdown-divider"></div>
<a href="/admin" class="dropdown-item" style="display: none;">
🛠️ Admin
</a>
<div class="dropdown-divider" style="display: none;"></div>
<a href="/logout" class="dropdown-item">
🚪 Sign Out
</a>
</div>
</div>
</div>
<!-- Logged out state -->
<div class="nav-login-prompt">
<a href="/login" class="btn-nav-login">Log In</a>
<a href="/signup" class="btn-nav-signup">Sign Up</a>
</div>
</div>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon"></span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{{ filterset_name|title or 'All Posts' }}</h1>
<p class="post-count">{{ posts|length }} posts</p>
</header>
<div id="posts-container">
{% for post in posts %}
{{ post|safe }}
{% endfor %}
</div>
</div>
</main>
</div>
{% for js_path in theme.js_dependencies %}
<script src="{{ js_path }}"></script>
{% endfor %}
<script>
// Dropdown functionality
function toggleDropdown() {
const dropdown = document.getElementById('userDropdown');
dropdown.classList.toggle('show');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('userDropdown');
const toggle = document.querySelector('.hamburger-toggle');
if (!toggle.contains(event.target) && !dropdown.contains(event.target)) {
dropdown.classList.remove('show');
}
});
// Check user authentication state (this would be dynamic in a real app)
function checkAuthState() {
// This would normally check with the server
// For now, we'll show the logged out state
document.querySelector('.nav-user-info').style.display = 'none';
document.querySelector('.nav-login-prompt').style.display = 'flex';
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', checkAuthState);
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!-- List Template - Compact list view -->
<template id="post-list-template">
<div class="post-list-item" data-post-id="{{id}}" data-platform="{{platform}}">
<div class="post-vote">
<span class="vote-score">{{score}}</span>
</div>
<div class="post-main">
<h3 class="post-title">
<a href="{{post_url}}" target="_blank" rel="noopener">{{title}}</a>
</h3>
<div class="post-metadata">
<span class="platform-badge platform-{{platform}}">{{platform}}</span>
{% if source %}<span class="post-source">{{source}}</span>{% endif %}
<span class="post-author">u/{{author}}</span>
<time class="post-time" datetime="{{timestamp}}">{{formatTimeAgo(timestamp)}}</time>
<span class="post-replies">{{replies}} comments</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
/**
* Vanilla JS Template Renderer
* Renders posts using HTML template literals
*/
class VanillaRenderer {
constructor() {
this.templates = new Map();
this.formatters = {
formatTime: this.formatTime.bind(this),
formatTimeAgo: this.formatTimeAgo.bind(this),
formatDateTime: this.formatDateTime.bind(this),
truncate: this.truncate.bind(this),
renderMarkdown: this.renderMarkdown.bind(this)
};
}
/**
* Load a template from HTML file
*/
async loadTemplate(templateId, templatePath) {
const response = await fetch(templatePath);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const template = doc.querySelector('template');
if (template) {
this.templates.set(templateId, template.innerHTML);
}
}
/**
* Render a post using a template
*/
render(templateId, postData) {
const template = this.templates.get(templateId);
if (!template) {
throw new Error(`Template ${templateId} not loaded`);
}
// Create context with data and helper functions
const context = { ...postData, ...this.formatters };
// Use Function constructor to evaluate template literal
const rendered = new Function(...Object.keys(context), `return \`${template}\`;`)(...Object.values(context));
return rendered;
}
/**
* Render multiple posts
*/
renderBatch(templateId, posts, container) {
const fragment = document.createDocumentFragment();
posts.forEach(post => {
const html = this.render(templateId, post);
const temp = document.createElement('div');
temp.innerHTML = html;
fragment.appendChild(temp.firstElementChild);
});
container.appendChild(fragment);
}
// Helper functions available in templates
formatTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
formatTimeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60
};
for (const [unit, secondsInUnit] of Object.entries(intervals)) {
const interval = Math.floor(seconds / secondsInUnit);
if (interval >= 1) {
return `${interval} ${unit}${interval !== 1 ? 's' : ''} ago`;
}
}
return 'just now';
}
formatDateTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
truncate(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + '...';
}
renderMarkdown(text) {
// Basic markdown rendering (expand as needed)
return text
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>');
}
}
// Export for use
export default VanillaRenderer;

View File

@@ -0,0 +1,336 @@
/* Vanilla JS Theme Styles */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f7f8;
--bg-hover: #f0f1f2;
--text-primary: #1c1c1c;
--text-secondary: #7c7c7c;
--border-color: #e0e0e0;
--accent-reddit: #ff4500;
--accent-hn: #ff6600;
--accent-lobsters: #990000;
--accent-se: #0077cc;
}
/* Card Template Styles */
.post-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
transition: box-shadow 0.2s, transform 0.2s;
}
.post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.post-header {
margin-bottom: 12px;
}
.post-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.platform-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.platform-reddit { background: var(--accent-reddit); color: white; }
.platform-hackernews { background: var(--accent-hn); color: white; }
.platform-lobsters { background: var(--accent-lobsters); color: white; }
.platform-stackexchange { background: var(--accent-se); color: white; }
.post-source {
color: var(--text-secondary);
font-size: 14px;
}
.post-title {
margin: 0 0 12px 0;
font-size: 18px;
line-height: 1.4;
}
.post-title a {
color: var(--text-primary);
text-decoration: none;
}
.post-title a:hover {
text-decoration: underline;
}
.post-info {
display: flex;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.post-content {
margin-bottom: 12px;
}
.post-excerpt {
color: var(--text-secondary);
line-height: 1.6;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.post-stats {
display: flex;
gap: 16px;
font-size: 14px;
}
.stat-score,
.stat-replies {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
}
.post-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag {
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--text-secondary);
}
/* List Template Styles */
.post-list-item {
display: flex;
gap: 12px;
padding: 12px;
border-bottom: 1px solid var(--border-color);
transition: background 0.2s;
}
.post-list-item:hover {
background: var(--bg-hover);
}
.post-vote {
display: flex;
flex-direction: column;
align-items: center;
min-width: 40px;
}
.vote-score {
font-weight: 600;
font-size: 14px;
color: var(--text-secondary);
}
.post-main {
flex: 1;
}
.post-list-item .post-title {
margin: 0 0 8px 0;
font-size: 16px;
}
.post-metadata {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
flex-wrap: wrap;
}
/* Detail Template Styles */
.post-detail {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.separator {
color: var(--text-secondary);
}
.detail-title {
font-size: 32px;
line-height: 1.3;
margin: 0 0 16px 0;
}
.detail-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-bottom: 24px;
}
.author-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.author-name {
font-weight: 600;
font-size: 16px;
}
.detail-content {
line-height: 1.8;
margin-bottom: 24px;
}
.detail-content p {
margin-bottom: 16px;
}
.detail-tags {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.detail-footer {
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.source-link-btn {
display: inline-block;
padding: 12px 24px;
background: var(--accent-reddit);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: opacity 0.2s;
}
.source-link-btn:hover {
opacity: 0.9;
}
/* Comment Styles */
.comments-section {
margin-top: 32px;
padding-top: 24px;
border-top: 2px solid var(--border-color);
}
.comment {
background: var(--bg-primary);
border-left: 2px solid var(--border-color);
padding: 12px;
margin-bottom: 8px;
transition: background 0.2s;
}
.comment:hover {
background: var(--bg-hover);
}
.comment-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 14px;
}
.comment-author {
font-weight: 600;
color: var(--text-primary);
}
.comment-time {
color: var(--text-secondary);
font-size: 12px;
}
.comment-score {
color: var(--text-secondary);
font-size: 12px;
margin-left: auto;
}
.comment-body {
margin-bottom: 8px;
}
.comment-content {
margin: 0;
line-height: 1.6;
color: var(--text-primary);
}
.comment-content p {
margin: 0 0 8px 0;
}
.comment-footer {
font-size: 12px;
color: var(--text-secondary);
}
.comment-depth-indicator {
opacity: 0.6;
}
.comment-children {
margin-top: 8px;
}
/* Depth-based styling */
.comment[data-depth="0"] {
border-left-color: var(--accent-reddit);
}
.comment[data-depth="1"] {
border-left-color: var(--accent-hn);
}
.comment[data-depth="2"] {
border-left-color: var(--accent-lobsters);
}
.comment[data-depth="3"] {
border-left-color: var(--accent-se);
}

View File

@@ -0,0 +1,56 @@
{
"template_id": "vanilla-js-theme",
"template_path": "./themes/vanilla-js",
"template_type": "card",
"data_schema": "../../schemas/post_schema.json",
"required_fields": [
"platform",
"id",
"title",
"author",
"timestamp",
"score",
"replies",
"url"
],
"optional_fields": [
"content",
"source",
"tags",
"meta"
],
"css_dependencies": [
"./themes/vanilla-js/styles.css"
],
"js_dependencies": [
"./themes/vanilla-js/renderer.js"
],
"templates": {
"card": "./themes/vanilla-js/card-template.html",
"list": "./themes/vanilla-js/list-template.html",
"detail": "./themes/vanilla-js/detail-template.html",
"comment": "./themes/vanilla-js/comment-template.html"
},
"render_options": {
"container_selector": "#posts-container",
"batch_size": 50,
"lazy_load": true,
"animate": true
},
"filters": {
"platform": true,
"date_range": true,
"score_threshold": true,
"source": true
},
"sorting": {
"default_field": "timestamp",
"default_order": "desc",
"available_fields": [
"timestamp",
"score",
"replies",
"title"
]
}
}