1600 lines
44 KiB
HTML
Raw Normal View History

2025-10-02 13:22:43 +02:00
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnFiRe</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #dae0e6;
color: #1c1c1c;
line-height: 1.5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #ff4500;
color: white;
padding: 10px 20px;
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 24px;
cursor: pointer;
}
.header-nav {
display: flex;
gap: 15px;
align-items: center;
}
.header-nav button {
background: white;
color: #ff4500;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.header-nav button:hover {
background: #f0f0f0;
}
.main-content {
display: flex;
gap: 20px;
margin-top: 20px;
}
.sidebar {
width: 250px;
flex-shrink: 0;
}
.sidebar-card {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
}
.sidebar-card h3 {
font-size: 14px;
margin-bottom: 10px;
text-transform: uppercase;
color: #7c7c7c;
}
.sidebar-list {
list-style: none;
}
.sidebar-list li {
padding: 5px 0;
cursor: pointer;
color: #0079d3;
}
.sidebar-list li:hover {
text-decoration: underline;
}
.post-card {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 10px;
display: flex;
}
.vote-section {
width: 40px;
background: #f8f9fa;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
border-right: 1px solid #ccc;
}
.vote-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
color: #878a8c;
padding: 2px;
}
.vote-btn:hover {
color: #ff4500;
}
.vote-btn.upvoted {
color: #ff4500;
}
.vote-btn.downvoted {
color: #7193ff;
}
.vote-score {
font-size: 12px;
font-weight: 700;
color: #1c1c1c;
margin: 4px 0;
}
.post-content {
flex: 1;
padding: 8px 12px;
}
.post-meta {
font-size: 12px;
color: #7c7c7c;
margin-bottom: 5px;
}
.post-meta a {
color: #0079d3;
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.post-meta a:hover {
text-decoration: underline;
}
.post-title {
font-size: 18px;
font-weight: 500;
margin-bottom: 5px;
cursor: pointer;
}
.post-title:hover {
color: #0079d3;
}
.post-body {
font-size: 14px;
margin-bottom: 10px;
color: #1c1c1c;
}
.post-actions {
display: flex;
gap: 10px;
font-size: 12px;
}
.post-action {
background: none;
border: none;
cursor: pointer;
color: #878a8c;
font-weight: 700;
padding: 4px 8px;
}
.post-action:hover {
background: #f8f9fa;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 4px;
padding: 20px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #edeff1;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
font-size: 14px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
font-size: 14px;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
.btn-primary {
background: #0079d3;
color: white;
}
.btn-primary:hover {
background: #0060a0;
}
.btn-secondary {
background: #f0f0f0;
color: #1c1c1c;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.sort-tabs {
display: flex;
gap: 5px;
margin-bottom: 15px;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
}
.sort-tab, .post-sort-tab, .comment-sort-tab {
background: none;
border: none;
padding: 6px 12px;
cursor: pointer;
font-weight: 600;
color: #878a8c;
border-radius: 4px;
}
.sort-tab:hover, .post-sort-tab:hover, .comment-sort-tab:hover {
background: #f8f9fa;
}
.sort-tab.active, .post-sort-tab.active, .comment-sort-tab.active {
background: #0079d3;
color: white;
}
.comment {
border-left: 2px solid #edeff1;
padding-left: 10px;
margin: 10px 0;
}
.comment-header {
font-size: 12px;
color: #7c7c7c;
margin-bottom: 5px;
}
.comment-header strong {
color: #1c1c1c;
cursor: pointer;
}
.comment-body {
font-size: 14px;
margin-bottom: 5px;
}
.comment-actions {
display: flex;
gap: 10px;
font-size: 12px;
}
.comment-reply {
margin-left: 20px;
}
.hidden {
display: none;
}
.search-bar {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.search-bar input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.badge {
display: inline-block;
padding: 2px 6px;
background: #0079d3;
color: white;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
margin-left: 5px;
}
.badge-nsfw {
background: #ff4500;
}
.badge-spoiler {
background: #333;
}
.badge-sticky {
background: #46d160;
}
.badge-locked {
background: #ffd635;
color: #1c1c1c;
}
.user-karma {
font-size: 12px;
color: #7c7c7c;
}
.message-item {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.message-header {
font-size: 12px;
color: #7c7c7c;
margin-bottom: 5px;
}
.message-subject {
font-weight: 600;
margin-bottom: 5px;
}
.message-body {
font-size: 14px;
}
.time-filters {
display: flex;
gap: 5px;
margin-left: 10px;
}
.time-filter {
background: none;
border: 1px solid #ccc;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
border-radius: 3px;
}
.time-filter.active {
background: #0079d3;
color: white;
border-color: #0079d3;
}
.flair-tag {
display: inline-block;
padding: 2px 6px;
background: #edeff1;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 11px;
margin-right: 5px;
}
</style>
</head>
<body>
<script>
class App {
constructor() {
this.currentUser = null;
this.sessionToken = null;
this.currentView = 'home';
this.currentSort = 'hot';
this.currentTimeFilter = 'all';
this.currentCommunity = null;
this.currentPost = null;
this.currentCommentSort = 'best';
window.addEventListener('popstate', (event) => {
this.handlePopState(event);
});
document.addEventListener('DOMContentLoaded', () => {
this.init();
});
}
async init() {
try {
this.sessionToken = localStorage.getItem('sessionToken');
} catch (e) {
console.error('localStorage error:', e);
this.sessionToken = null;
}
if (this.sessionToken) {
const result = await this.api('/me');
if (result.success) {
this.currentUser = result.user;
this.updateUI();
} else {
this.sessionToken = null;
try {
localStorage.removeItem('sessionToken');
} catch (e) {
console.error('localStorage error:', e);
}
}
}
this.updateUI();
await this.loadCommunities();
this.handleRoute(window.location.search);
// Add Escape key listener for modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
}
async handleRoute(search) {
const params = new URLSearchParams(search);
if (params.has('post')) {
const postId = params.get('post');
await this.showPost(Number(postId), false);
} else if (params.has('community')) {
const communityId = params.get('community');
await this.showCommunity(Number(communityId), false);
} else if (params.has('user')) {
const username = params.get('user');
await this.showProfile(username, false);
} else if (params.has('messages')) {
await this.showMessages(false);
} else {
this.showHome(false);
}
}
updateURL(params, replace = false) {
const baseUrl = window.location.origin + window.location.pathname;
const urlParams = new URLSearchParams();
if (params.view) {
if (params.view === 'post' && params.postId) {
urlParams.set('post', params.postId);
} else if (params.view === 'community' && params.communityId) {
urlParams.set('community', params.communityId);
} else if (params.view === 'profile' && params.username) {
urlParams.set('user', params.username);
} else if (params.view === 'messages') {
urlParams.set('messages', '1');
}
}
const newUrl = urlParams.toString() ? `${baseUrl}?${urlParams.toString()}` : baseUrl;
if (replace) {
window.history.replaceState(params, '', newUrl);
} else {
window.history.pushState(params, '', newUrl);
}
}
handlePopState(event) {
if (!event.state) {
this.showHome(true);
return;
}
const state = event.state;
switch (state.view) {
case 'post':
this.showPost(state.postId, true);
break;
case 'community':
this.showCommunity(state.communityId, true);
break;
case 'profile':
this.showProfile(state.username, true);
break;
case 'messages':
this.showMessages(true);
break;
default:
this.showHome(true);
}
}
updateUI() {
if (this.currentUser) {
document.getElementById('user-info').classList.remove('hidden');
document.getElementById('user-info').innerHTML = `<span onclick="document.app.showProfile('${this.currentUser.username}')" style="cursor:pointer;color:white;">${this.currentUser.username}</span> <span class="user-karma">${this.currentUser.karma} karma</span>`;
document.getElementById('login-btn').classList.add('hidden');
document.getElementById('register-btn').classList.add('hidden');
document.getElementById('logout-btn').classList.remove('hidden');
document.getElementById('create-community-btn').classList.remove('hidden');
document.getElementById('create-post-btn').classList.remove('hidden');
document.getElementById('messages-btn').classList.remove('hidden');
document.getElementById('subscriptions-card').classList.remove('hidden');
this.loadSubscriptions();
} else {
document.getElementById('user-info').classList.add('hidden');
document.getElementById('login-btn').classList.remove('hidden');
document.getElementById('register-btn').classList.remove('hidden');
document.getElementById('logout-btn').classList.add('hidden');
document.getElementById('create-community-btn').classList.add('hidden');
document.getElementById('create-post-btn').classList.add('hidden');
document.getElementById('messages-btn').classList.add('hidden');
document.getElementById('subscriptions-card').classList.add('hidden');
}
}
async api(endpoint, data = null) {
const options = {
method: data ? 'POST' : 'GET',
headers: {'Content-Type': 'application/json'}
};
if (this.sessionToken) {
options.headers['X-Session-Token'] = this.sessionToken;
}
if (data) {
options.body = JSON.stringify(data);
}
try {
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
const response = await fetch(`${protocol}://${window.location.host}/api${endpoint}`, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API error:', error);
return {success: false, error: 'Network error. Please try again.'};
}
}
showLogin() {
document.getElementById('modal-title').textContent = 'Login';
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label>Username</label>
<input type="text" id="login-username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="login-password">
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="document.app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="document.app.login()">Login</button>
</div>
`;
document.getElementById('modal').classList.add('active');
}
async login() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) {
alert('Please enter both username and password');
return;
}
const result = await this.api('/login', {username, password});
if (result.success) {
this.sessionToken = result.token;
try {
localStorage.setItem('sessionToken', result.token);
} catch (e) {
console.error('localStorage error:', e);
}
this.currentUser = {
id: result.user_id,
username: result.username,
karma: result.karma
};
this.updateUI();
this.closeModal();
this.showHome();
} else {
alert(result.error || 'Login failed');
}
}
showRegister() {
document.getElementById('modal-title').textContent = 'Register';
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label>Username</label>
<input type="text" id="register-username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="register-password">
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="document.app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="document.app.register()">Register</button>
</div>
`;
document.getElementById('modal').classList.add('active');
}
async register() {
const username = document.getElementById('register-username').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
alert('Please enter both username and password');
return;
}
if (username.length < 3) {
alert('Username must be at least 3 characters');
return;
}
if (password.length < 6) {
alert('Password must be at least 6 characters');
return;
}
const result = await this.api('/register', {username, password});
if (result.success) {
// Auto-login after successful registration
this.sessionToken = result.token;
try {
localStorage.setItem('sessionToken', result.token);
} catch (e) {
console.error('localStorage error:', e);
}
this.currentUser = {
username: result.username,
karma: result.karma
};
this.updateUI();
this.closeModal();
this.showHome();
} else {
alert(result.error || 'Registration failed');
}
}
async logout() {
await this.api('/logout');
this.currentUser = null;
this.sessionToken = null;
try {
localStorage.removeItem('sessionToken');
} catch (e) {
console.error('localStorage error:', e);
}
this.updateUI();
this.showHome();
}
async loadCommunities() {
const result = await this.api('/communities');
const list = document.getElementById('communities-list');
list.innerHTML = '';
result.communities.forEach(community => {
const li = document.createElement('li');
li.textContent = `${community.name} (${community.subscribers})`;
li.onclick = () => this.showCommunity(community.id);
list.appendChild(li);
});
}
async loadSubscriptions() {
if (!this.currentUser) return;
const result = await this.api(`/subscriptions`);
const list = document.getElementById('subscriptions-list');
list.innerHTML = '';
result.subscriptions.forEach(sub => {
const li = document.createElement('li');
li.textContent = sub.name;
li.onclick = () => this.showCommunity(sub.id);
list.appendChild(li);
});
}
showCreateCommunity() {
if (!this.currentUser) {
alert('Please login first');
return;
}
document.getElementById('modal-title').textContent = 'Create Community';
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label>Name</label>
<input type="text" id="community-name">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="community-description"></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="document.app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="document.app.createCommunity()">Create</button>
</div>
`;
document.getElementById('modal').classList.add('active');
}
async createCommunity() {
const name = document.getElementById('community-name').value.trim();
const description = document.getElementById('community-description').value.trim();
if (!name) {
alert('Please enter a community name');
return;
}
if (name.length < 3) {
alert('Community name must be at least 3 characters');
return;
}
const result = await this.api('/create_community', {
name,
description
});
if (result.success) {
this.closeModal();
this.loadCommunities();
this.showCommunity(result.community_id);
} else {
alert(result.error || 'Failed to create community');
}
}
showCreatePost() {
if (!this.currentUser) {
alert('Please login first');
return;
}
this.api('/communities').then(result => {
let options = result.communities.map(c =>
`<option value="${c.id}">${c.name}</option>`
).join('');
document.getElementById('modal-title').textContent = 'Create Post';
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label>Community</label>
<select id="post-community">${options}</select>
</div>
<div class="form-group">
<label>Title</label>
<input type="text" id="post-title">
</div>
<div class="form-group">
<label>Type</label>
<select id="post-type" onchange="document.app.togglePostType()">
<option value="text">Text</option>
<option value="link">Link</option>
</select>
</div>
<div class="form-group" id="post-content-group">
<label>Content</label>
<textarea id="post-content"></textarea>
</div>
<div class="form-group hidden" id="post-url-group">
<label>URL</label>
<input type="text" id="post-url">
</div>
<div class="form-group">
<label>Flair (optional)</label>
<input type="text" id="post-flair">
</div>
<div class="form-group">
<label><input type="checkbox" id="post-nsfw"> NSFW</label>
<label><input type="checkbox" id="post-spoiler"> Spoiler</label>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="document.app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="document.app.createPost()">Post</button>
</div>
`;
document.getElementById('modal').classList.add('active');
});
}
togglePostType() {
const type = document.getElementById('post-type').value;
if (type === 'text') {
document.getElementById('post-content-group').classList.remove('hidden');
document.getElementById('post-url-group').classList.add('hidden');
} else {
document.getElementById('post-content-group').classList.add('hidden');
document.getElementById('post-url-group').classList.remove('hidden');
}
}
async createPost() {
const community_id = document.getElementById('post-community').value;
const title = document.getElementById('post-title').value.trim();
const type = document.getElementById('post-type').value;
const content = type === 'text' ? document.getElementById('post-content').value.trim() : '';
const url = type === 'link' ? document.getElementById('post-url').value.trim() : '';
const flair = document.getElementById('post-flair').value.trim();
const nsfw = document.getElementById('post-nsfw').checked ? 1 : 0;
const spoiler = document.getElementById('post-spoiler').checked ? 1 : 0;
if (!title) {
alert('Please enter a title');
return;
}
if (title.length < 3) {
alert('Title must be at least 3 characters');
return;
}
if (type === 'link' && !url) {
alert('Please enter a URL');
return;
}
const result = await this.api('/create_post', {
community_id,
title,
content,
url,
type,
flair,
nsfw,
spoiler
});
if (result.success) {
this.closeModal();
this.showPost(result.post_id);
} else {
alert(result.error || 'Failed to create post');
}
}
async loadFeed(append) {
let endpoint = `/posts?sort=${this.currentSort}&time=${this.currentTimeFilter}`;
if (this.currentCommunity) {
endpoint += `&community_id=${this.currentCommunity}`;
}
if (this.currentUser && this.currentView === 'home') {
endpoint += '&feed=home';
}
const result = await this.api(endpoint);
this.renderPosts(result.posts, append);
}
renderPosts(posts, append) {
const feed = document.getElementById('feed-content');
if (!append) {
feed.innerHTML = '';
}
posts.forEach(post => {
const postEl = document.createElement('div');
postEl.className = 'post-card';
let badges = '';
if (post.sticky) badges += '<span class="badge badge-sticky">STICKY</span>';
if (post.nsfw) badges += '<span class="badge badge-nsfw">NSFW</span>';
if (post.spoiler) badges += '<span class="badge badge-spoiler">SPOILER</span>';
if (post.locked) badges += '<span class="badge badge-locked">LOCKED</span>';
if (post.flair) badges += `<span class="flair-tag">${post.flair}</span>`;
postEl.innerHTML = `
<div class="vote-section">
<button class="vote-btn ${post.user_vote === 1 ? 'upvoted' : ''}" onclick="document.app.vote('post', ${post.id}, 1)">â–²</button>
<div class="vote-score">${post.score}</div>
<button class="vote-btn ${post.user_vote === -1 ? 'downvoted' : ''}" onclick="document.app.vote('post', ${post.id}, -1)">â–¼</button>
</div>
<div class="post-content">
<div class="post-meta">
<a onclick="document.app.showCommunity(${post.community_id})">${post.community_name}</a> •
Posted by <a onclick="document.app.showProfile('${post.username}')">${post.username}</a> •
${this.formatTime(post.created_at)}
${badges}
</div>
<div class="post-title" onclick="document.app.showPost(${post.id})">${post.title}</div>
${post.content ? `<div class="post-body">${this.renderMarkdown(post.content.substring(0, 300))}${post.content.length > 300 ? '...' : ''}</div>` : ''}
${post.url ? `<div class="post-body"><a href="${post.url}" target="_blank">${post.url}</a></div>` : ''}
<div class="post-actions">
<button class="post-action" onclick="document.app.showPost(${post.id})">💬 ${post.comment_count} Comments</button>
<button class="post-action" onclick="document.app.savePost(${post.id})">Save</button>
<button class="post-action" onclick="document.app.hidePost(${post.id})">Hide</button>
<button class="post-action" onclick="document.app.sharePost(${post.id})">Share</button>
</div>
</div>
`;
feed.appendChild(postEl);
});
}
async showPost(postId, fromPopState = false) {
const postResult = await this.api(`/post?id=${postId}`);
const commentsResult = await this.api(`/comments?post_id=${postId}&sort=${this.currentCommentSort}`);
if (!postResult.success) {
alert('Post not found');
return;
}
this.currentPost = postResult.post;
this.currentView = 'post';
this.currentCommunity = null;
this.currentSort = 'hot';
this.currentTimeFilter = 'all';
if (!fromPopState) {
this.updateURL({view: 'post', postId});
}
const feed = document.getElementById('feed-content');
feed.innerHTML = '';
const post = postResult.post;
let badges = '';
if (post.sticky) badges += '<span class="badge badge-sticky">STICKY</span>';
if (post.nsfw) badges += '<span class="badge badge-nsfw">NSFW</span>';
if (post.spoiler) badges += '<span class="badge badge-spoiler">SPOILER</span>';
if (post.locked) badges += '<span class="badge badge-locked">LOCKED</span>';
if (post.flair) badges += `<span class="flair-tag">${post.flair}</span>`;
const postEl = document.createElement('div');
postEl.className = 'post-card';
postEl.innerHTML = `
<div class="vote-section">
<button class="vote-btn ${post.user_vote === 1 ? 'upvoted' : ''}" onclick="document.app.vote('post', ${post.id}, 1)">â–²</button>
<div class="vote-score">${post.score}</div>
<button class="vote-btn ${post.user_vote === -1 ? 'downvoted' : ''}" onclick="document.app.vote('post', ${post.id}, -1)">â–¼</button>
</div>
<div class="post-content">
<div class="post-meta">
<a onclick="document.app.showCommunity(${post.community_id})">${post.community_name}</a> •
Posted by <a onclick="document.app.showProfile('${post.username}')">${post.username}</a> •
${this.formatTime(post.created_at)}
${badges}
</div>
<div class="post-title">${post.title}</div>
${post.content ? `<div class="post-body">${this.renderMarkdown(post.content)}</div>` : ''}
${post.url ? `<div class="post-body"><a href="${post.url}" target="_blank">${post.url}</a></div>` : ''}
<div class="post-actions">
<button class="post-action" onclick="document.app.showCommentForm(${post.id}, null)">💬 Add Comment</button>
<button class="post-action" onclick="document.app.savePost(${post.id})">Save</button>
<button class="post-action" onclick="document.app.hidePost(${post.id})">Hide</button>
</div>
</div>
`;
feed.appendChild(postEl);
const sortTabs = document.createElement('div');
sortTabs.className = 'sort-tabs';
sortTabs.innerHTML = `
<button class="comment-sort-tab ${this.currentCommentSort === 'best' ? 'active' : ''}" data-sort="best">Best</button>
<button class="comment-sort-tab ${this.currentCommentSort === 'top' ? 'active' : ''}" data-sort="top">Top</button>
<button class="comment-sort-tab ${this.currentCommentSort === 'new' ? 'active' : ''}" data-sort="new">New</button>
<button class="comment-sort-tab ${this.currentCommentSort === 'old' ? 'active' : ''}" data-sort="old">Old</button>
<button class="comment-sort-tab ${this.currentCommentSort === 'controversial' ? 'active' : ''}" data-sort="controversial">Controversial</button>
`;
sortTabs.addEventListener('click', (e) => {
if (e.target.classList.contains('comment-sort-tab')) {
const sort = e.target.dataset.sort;
this.changeCommentSort(postId, sort);
}
});
feed.appendChild(sortTabs);
const commentsContainer = document.createElement('div');
commentsContainer.id = 'comments-container';
this.renderComments(commentsResult.comments, commentsContainer);
feed.appendChild(commentsContainer);
}
renderComments(comments, container, parentId = null, level = 0) {
const filteredComments = comments.filter(c => c.parent_id === parentId);
filteredComments.forEach(comment => {
const commentEl = document.createElement('div');
commentEl.className = level > 0 ? 'comment comment-reply' : 'comment';
commentEl.innerHTML = `
<div class="comment-header">
<strong onclick="document.app.showProfile('${comment.username}')">${comment.username}</strong> •
${comment.score} points •
${this.formatTime(comment.created_at)}
</div>
<div class="comment-body">${this.renderMarkdown(comment.content)}</div>
<div class="comment-actions">
<button class="post-action ${comment.user_vote === 1 ? 'upvoted' : ''}" onclick="document.app.vote('comment', ${comment.id}, 1)">â–²</button>
<button class="post-action ${comment.user_vote === -1 ? 'downvoted' : ''}" onclick="document.app.vote('comment', ${comment.id}, -1)">â–¼</button>
<button class="post-action" onclick="document.app.showCommentForm(${comment.post_id}, ${comment.id})">Reply</button>
<button class="post-action" onclick="document.app.saveComment(${comment.id})">Save</button>
</div>
`;
container.appendChild(commentEl);
const children = comments.filter(c => c.parent_id === comment.id);
if (children.length > 0) {
const childContainer = document.createElement('div');
commentEl.appendChild(childContainer);
this.renderComments(comments, childContainer, comment.id, level + 1);
}
});
}
showCommentForm(postId, parentId) {
if (!this.currentUser) {
alert('Please login to comment');
return;
}
document.getElementById('modal-title').textContent = parentId ? 'Reply to Comment' : 'Add Comment';
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label>Comment</label>
<textarea id="comment-content"></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="document.app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="document.app.postComment(${postId}, ${parentId})">Post</button>
</div>
`;
document.getElementById('modal').classList.add('active');
}
async postComment(postId, parentId) {
const content = document.getElementById('comment-content').value.trim();
if (!content) {
alert('Please enter a comment');
return;
}
if (content.length < 1) {
alert('Comment cannot be empty');
return;
}
const result = await this.api('/comment', {
post_id: postId,
parent_id: parentId,
content
});
if (result.success) {
this.closeModal();
this.showPost(postId);
} else {
alert(result.error || 'Failed to post comment');
}
}
async changeCommentSort(postId, sort) {
this.currentCommentSort = sort;
const commentsResult = await this.api(`/comments?post_id=${postId}&sort=${sort}`);
const container = document.getElementById('comments-container');
container.innerHTML = '';
this.renderComments(commentsResult.comments, container);
document.querySelectorAll('.comment-sort-tab').forEach(tab => tab.classList.remove('active'));
const activeTab = [...document.querySelectorAll('.comment-sort-tab')].find(tab => tab.dataset.sort === sort);
if (activeTab) activeTab.classList.add('active');
}
async vote(targetType, targetId, voteValue) {
if (!this.currentUser) {
alert('Please login to vote');
return;
}
await this.api('/vote', {
target_type: targetType,
target_id: targetId,
vote: voteValue
});
if (this.currentPost) {
this.showPost(this.currentPost.id);
} else {
this.loadFeed();
}
}
async showCommunity(communityId, fromPopState = false) {
this.currentCommunity = communityId;
this.currentView = 'community';
this.currentPost = null;
this.currentCommentSort = 'best';
this.currentSort = 'hot';
this.currentTimeFilter = 'all';
if (!fromPopState) {
this.updateURL({view: 'community', communityId});
}
const result = await this.api(`/community?id=${communityId}`);
if (result.success) {
const community = result.community;
const feed = document.getElementById('feed-content');
const infoCard = document.createElement('div');
infoCard.className = 'sidebar-card';
infoCard.innerHTML = `
<h2>${community.name}</h2>
<p>${community.description || 'No description'}</p>
${this.currentUser ? `<button class="btn btn-primary" onclick="document.app.toggleSubscription(${community.id})">Subscribe</button>` : ''}
${community.sidebar ? `<div style="margin-top:10px;">${this.renderMarkdown(community.sidebar)}</div>` : ''}
`;
feed.innerHTML = '';
feed.appendChild(infoCard);
}
this.loadFeed(true);
}
async toggleSubscription(communityId) {
if (!this.currentUser) {
alert('Please login to subscribe');
return;
}
await this.api('/subscribe', {
community_id: communityId,
action: 'subscribe'
});
this.loadSubscriptions();
this.loadFeed(false);
}
showHome(fromPopState = false) {
this.currentCommunity = null;
this.currentPost = null;
this.currentView = 'home';
this.currentCommentSort = 'best';
this.currentSort = 'hot';
this.currentTimeFilter = 'all';
if (!fromPopState) {
this.updateURL({}, false);
}
this.loadFeed();
}
changeSort(sort) {
this.currentSort = sort;
document.querySelectorAll('.post-sort-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.sort === sort) {
tab.classList.add('active');
}
});
const timeFilters = document.getElementById('time-filters');
if (sort === 'top') {
timeFilters.classList.remove('hidden');
} else {
timeFilters.classList.add('hidden');
this.currentTimeFilter = 'all';
}
this.loadFeed();
}
changeTimeFilter(time) {
this.currentTimeFilter = time;
document.querySelectorAll('.time-filter').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.time === time) {
btn.classList.add('active');
}
});
this.loadFeed();
}
async search() {
const query = document.getElementById('search-input').value;
if (!query) return;
const result = await this.api(`/search?q=${encodeURIComponent(query)}`);
const feed = document.getElementById('feed-content');
feed.innerHTML = '<h2>Search Results</h2>';
if (result.posts.length > 0) {
const postsHeader = document.createElement('h3');
postsHeader.textContent = 'Posts';
feed.appendChild(postsHeader);
this.renderPosts(result.posts);
}
if (result.communities.length > 0) {
const commHeader = document.createElement('h3');
commHeader.textContent = 'Communities';
feed.appendChild(commHeader);
result.communities.forEach(community => {
const commEl = document.createElement('div');
commEl.className = 'sidebar-card';
commEl.innerHTML = `
<h3 onclick="document.app.showCommunity(${community.id})" style="cursor:pointer;">${community.name}</h3>
<p>${community.description}</p>
`;
feed.appendChild(commEl);
});
}
}
async showProfile(username, fromPopState = false) {
const result = await this.api(`/user?username=${username}`);
if (!result.success) {
alert('User not found');
return;
}
this.currentView = 'profile';
this.currentCommunity = null;
this.currentPost = null;
this.currentCommentSort = 'best';
if (!fromPopState) {
this.updateURL({view: 'profile', username});
}
const user = result.user;
const feed = document.getElementById('feed-content');
feed.innerHTML = '';
const profileCard = document.createElement('div');
profileCard.className = 'sidebar-card';
profileCard.innerHTML = `
<h2>${user.username}</h2>
<p>${user.karma} karma</p>
<p>Member since ${this.formatTime(user.created_at)}</p>
${this.currentUser && this.currentUser.username !== username ? `<button class="btn btn-primary" onclick="document.app.showSendMessage('${username}')">Send Message</button>` : ''}
`;
feed.appendChild(profileCard);
if (result.posts.length > 0) {
const postsHeader = document.createElement('h3');
postsHeader.textContent = 'Recent Posts';
feed.appendChild(postsHeader);
this.renderPosts(result.posts, true);
}
}
showSendMessage(toUsername) {
if (!this.currentUser) {
alert('Please login to send messages');
return;
}
document.getElementById('modal-title').textContent = 'Send Message';
document.getElementById('modal-body').innerHTML = `
<div class="form-group">
<label>To</label>
<input type="text" id="message-to" value="${toUsername}" readonly>
</div>
<div class="form-group">
<label>Subject</label>
<input type="text" id="message-subject">
</div>
<div class="form-group">
<label>Message</label>
<textarea id="message-content"></textarea>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="document.app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="document.app.sendMessage()">Send</button>
</div>
`;
document.getElementById('modal').classList.add('active');
}
async sendMessage() {
const to_username = document.getElementById('message-to').value.trim();
const subject = document.getElementById('message-subject').value.trim();
const content = document.getElementById('message-content').value.trim();
if (!to_username || !subject || !content) {
alert('Please fill in all fields');
return;
}
if (subject.length < 1) {
alert('Subject cannot be empty');
return;
}
if (content.length < 1) {
alert('Message cannot be empty');
return;
}
const result = await this.api('/message', {
to_username,
subject,
content
});
if (result.success) {
alert('Message sent!');
this.closeModal();
} else {
alert(result.error || 'Failed to send message');
}
}
async showMessages(fromPopState = false) {
if (!this.currentUser) {
alert('Please login to view messages');
return;
}
this.currentView = 'messages';
this.currentCommunity = null;
this.currentPost = null;
this.currentCommentSort = 'best';
if (!fromPopState) {
this.updateURL({view: 'messages'});
}
const result = await this.api(`/messages`);
const feed = document.getElementById('feed-content');
feed.innerHTML = '<h2>Messages</h2>';
result.messages.forEach(message => {
const msgEl = document.createElement('div');
msgEl.className = 'message-item';
msgEl.innerHTML = `
<div class="message-header">From ${message.from_username} • ${this.formatTime(message.created_at)}</div>
<div class="message-subject">${message.subject}</div>
<div class="message-body">${message.content}</div>
<button class="btn btn-secondary" onclick="document.app.showSendMessage('${message.from_username}')">Reply</button>
`;
feed.appendChild(msgEl);
});
}
async savePost(postId) {
if (!this.currentUser) {
alert('Please login to save posts');
return;
}
await this.api('/save', {
target_type: 'post',
target_id: postId,
action: 'save'
});
alert('Post saved!');
}
async hidePost(postId) {
if (!this.currentUser) {
alert('Please login to hide posts');
return;
}
await this.api('/hide', {
post_id: postId,
action: 'hide'
});
this.loadFeed();
}
sharePost(postId) {
const url = `${document.location.origin}/?post=${postId}`;
prompt('Share this link:', url);
}
renderMarkdown(text) {
let html = text;
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank">$1</a>');
return html;
}
formatTime(timestamp) {
const now = Math.floor(Date.now() / 1000);
const diff = now - timestamp;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`;
return `${Math.floor(diff / 31536000)}y ago`;
}
closeModal(event) {
if (!event || event.target.id === 'modal') {
document.getElementById('modal').classList.remove('active');
}
}
}
const app = new App();
document.app = app;
</script>
<div class="header">
<h1 onclick="document.app.showHome()">OnFiRe</h1>
<div class="header-nav">
<span id="user-info" class="hidden"></span>
<button id="login-btn" onclick="document.app.showLogin()">Login</button>
<button id="register-btn" onclick="document.app.showRegister()">Register</button>
<button id="logout-btn" class="hidden" onclick="document.app.logout()">Logout</button>
<button id="create-community-btn" class="hidden" onclick="document.app.showCreateCommunity()">Create Community</button>
<button id="create-post-btn" class="hidden" onclick="document.app.showCreatePost()">Create Post</button>
<button id="messages-btn" class="hidden" onclick="document.app.showMessages()">Messages</button>
</div>
</div>
<div class="container">
<div class="main-content">
<div class="sidebar">
<div class="sidebar-card">
<h3>Communities</h3>
<ul class="sidebar-list" id="communities-list"></ul>
</div>
<div class="sidebar-card hidden" id="subscriptions-card">
<h3>My Subscriptions</h3>
<ul class="sidebar-list" id="subscriptions-list"></ul>
</div>
</div>
<div class="feed">
<div class="search-bar">
<input type="text" id="search-input" placeholder="Search...">
<button class="btn btn-primary" onclick="document.app.search()">Search</button>
</div>
<div class="sort-tabs" id="post-sort-tabs">
<button class="post-sort-tab active" data-sort="hot" onclick="document.app.changeSort('hot')">Hot</button>
<button class="post-sort-tab" data-sort="new" onclick="document.app.changeSort('new')">New</button>
<button class="post-sort-tab" data-sort="top" onclick="document.app.changeSort('top')">Top</button>
<button class="post-sort-tab" data-sort="rising" onclick="document.app.changeSort('rising')">Rising</button>
<button class="post-sort-tab" data-sort="controversial" onclick="document.app.changeSort('controversial')">Controversial</button>
<div class="time-filters hidden" id="time-filters">
<button class="time-filter active" data-time="all" onclick="document.app.changeTimeFilter('all')">All</button>
<button class="time-filter" data-time="year" onclick="document.app.changeTimeFilter('year')">Year</button>
<button class="time-filter" data-time="month" onclick="document.app.changeTimeFilter('month')">Month</button>
<button class="time-filter" data-time="week" onclick="document.app.changeTimeFilter('week')">Week</button>
<button class="time-filter" data-time="day" onclick="document.app.changeTimeFilter('day')">Day</button>
<button class="time-filter" data-time="hour" onclick="document.app.changeTimeFilter('hour')">Hour</button>
</div>
</div>
<div id="feed-content"></div>
</div>
</div>
</div>
<div id="modal" class="modal" onclick="document.app.closeModal(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header" id="modal-title"></div>
<div id="modal-body"></div>
</div>
</div>
</body>
</html>