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

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"
]
}
}