<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rant Community</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #d55161;
--primary-dark: #c44154;
--secondary: #7bc8a4;
--background: #0a0a0a;
--surface: #1a1a1a;
--surface-light: #2a2a2a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--success: #4caf50;
--error: #f44336;
--warning: #ff9800;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--background);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* Navigation */
nav {
background: var(--surface);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-links a {
color: var(--text);
text-decoration: none;
transition: color 0.3s;
}
.nav-links a:hover {
color: var(--primary);
}
.btn {
background: var(--primary);
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--surface-light);
}
.btn-secondary:hover {
background: #3a3a3a;
}
/* Container */
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 2rem;
}
/* Rant Card */
.rant-card {
background: var(--surface);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
transition: transform 0.2s;
cursor: pointer;
}
.rant-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.rant-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 1rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
}
.user-info {
flex: 1;
}
.username {
font-weight: bold;
color: var(--primary);
}
.score {
color: var(--text-dim);
font-size: 0.9rem;
}
.rant-content {
margin-bottom: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.rant-image {
max-width: 100%;
border-radius: 4px;
margin: 1rem 0;
}
.rant-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-dim);
font-size: 0.9rem;
}
.rant-actions {
display: flex;
gap: 1rem;
}
.action-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.3rem;
transition: color 0.3s;
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.action-btn:hover {
color: var(--primary);
background: rgba(213, 81, 97, 0.1);
}
.action-btn.voted {
color: var(--primary);
}
.action-btn.downvoted {
color: var(--error);
}
/* Tags */
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.5rem 0;
}
.tag {
background: var(--surface-light);
padding: 0.2rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
color: var(--secondary);
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-dim);
}
input, textarea, select {
width: 100%;
padding: 0.75rem;
background: var(--surface-light);
border: 1px solid transparent;
border-radius: 4px;
color: var(--text);
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--primary);
}
textarea {
resize: vertical;
min-height: 120px;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 2000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s;
}
.close-btn:hover {
background: var(--surface-light);
color: var(--text);
}
/* Loading */
.loading {
text-align: center;
padding: 2rem;
color: var(--text-dim);
}
.spinner {
border: 3px solid var(--surface-light);
border-top: 3px solid var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Comments */
.comments-section {
background: var(--surface);
border-radius: 8px;
padding: 1.5rem;
margin-top: 2rem;
}
.comment {
padding: 1rem 0;
border-bottom: 1px solid var(--surface-light);
}
.comment:last-child {
border-bottom: none;
}
.comment-form {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--surface-light);
}
/* Sort Options */
.sort-options {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
justify-content: center;
}
.sort-btn {
background: var(--surface);
border: 1px solid var(--surface-light);
padding: 0.5rem 1.5rem;
border-radius: 20px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.3s;
}
.sort-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.sort-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Profile */
.profile-header {
background: var(--surface);
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin: 0 auto 1rem;
font-size: 2rem;
}
.profile-stats {
display: flex;
justify-content: center;
gap: 3rem;
margin-top: 1.5rem;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary);
}
.stat-label {
color: var(--text-dim);
font-size: 0.9rem;
}
/* Tabs */
.tabs {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--surface-light);
}
.tab {
padding: 1rem 2rem;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
position: relative;
transition: color 0.3s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--primary);
}
/* Alert */
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.alert.active {
display: block;
}
.alert.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.alert.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid var(--error);
color: var(--error);
}
/* Search */
.search-box {
background: var(--surface);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.search-form {
display: flex;
gap: 1rem;
}
.search-form input {
flex: 1;
}
/* Floating Action Button */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 4px 20px rgba(213, 81, 97, 0.4);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 30px rgba(213, 81, 97, 0.6);
}
/* Responsive */
@media (max-width: 768px) {
.nav-links {
gap: 1rem;
}
.nav-links span {
display: none;
}
.profile-stats {
gap: 1.5rem;
}
.tabs {
gap: 0.5rem;
}
.tab {
padding: 1rem;
}
}
</style>
</head>
<body>
<!-- Navigation Component -->
<rant-navigation></rant-navigation>
<!-- Main Content Container -->
<div id="content" class="container"></div>
<!-- Floating Action Button -->
<rant-fab></rant-fab>
<!-- Modal Component -->
<rant-modal></rant-modal>
<script>
// Global Event Bus for Component Communication
class EventBus {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
}
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data));
}
}
}
const eventBus = new EventBus();
// Auth Manager Singleton
class AuthManager {
constructor() {
this.authToken = null;
this.currentUser = null;
this.API_URL = '/api';
this.APP_ID = 3;
this.checkAuth();
}
checkAuth() {
const token = localStorage.getItem('authToken');
if (token) {
this.authToken = JSON.parse(token);
this.currentUser = {
id: this.authToken.user_id,
token_id: this.authToken.id,
token_key: this.authToken.key
};
eventBus.emit('auth-changed', { isLoggedIn: true, user: this.currentUser });
} else {
eventBus.emit('auth-changed', { isLoggedIn: false });
}
}
async apiCall(endpoint, options = {}) {
let url = `${this.API_URL}${endpoint}`;
// Add auth to FormData or URLSearchParams if logged in
if (this.currentUser && options.body) {
if (options.body instanceof FormData) {
options.body.append('app', this.APP_ID);
options.body.append('token_id', this.currentUser.token_id);
options.body.append('token_key', this.currentUser.token_key);
options.body.append('user_id', this.currentUser.id);
} else if (options.body instanceof URLSearchParams) {
options.body.append('app', this.APP_ID);
options.body.append('token_id', this.currentUser.token_id);
options.body.append('token_key', this.currentUser.token_key);
options.body.append('user_id', this.currentUser.id);
}
}
// Add auth to query params for GET requests
if (this.currentUser && (options.method === 'GET' || !options.method)) {
const separator = endpoint.includes('?') ? '&' : '?';
url += `${separator}app=${this.APP_ID}&token_id=${this.currentUser.token_id}&token_key=${this.currentUser.token_key}&user_id=${this.currentUser.id}`;
}
try {
const response = await fetch(url, options);
const data = await response.json();
return data;
} catch (error) {
console.error('API Error:', error);
return { success: false, error: error.message };
}
}
async login(username, password) {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
formData.append('app', this.APP_ID);
const data = await this.apiCall('/users/auth-token', {
method: 'POST',
body: formData
});
if (data.success) {
this.authToken = data.auth_token;
this.currentUser = {
id: this.authToken.user_id,
token_id: this.authToken.id,
token_key: this.authToken.key
};
localStorage.setItem('authToken', JSON.stringify(this.authToken));
eventBus.emit('auth-changed', { isLoggedIn: true, user: this.currentUser });
}
return data;
}
logout() {
localStorage.removeItem('authToken');
this.authToken = null;
this.currentUser = null;
eventBus.emit('auth-changed', { isLoggedIn: false });
}
isLoggedIn() {
return !!this.currentUser;
}
}
const authManager = new AuthManager();
// Helper functions
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatTime(timestamp) {
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return date.toLocaleDateString();
}
// Navigation Component
class RantNavigation extends HTMLElement {
constructor() {
super();
this.notificationCount = 0;
this.checkNotificationInterval = null;
}
connectedCallback() {
this.render();
eventBus.on('auth-changed', (data) => this.handleAuthChange(data));
eventBus.on('navigate', (view) => this.handleNavigate(view));
if (authManager.isLoggedIn()) {
this.startNotificationCheck();
}
}
disconnectedCallback() {
eventBus.off('auth-changed', this.handleAuthChange);
eventBus.off('navigate', this.handleNavigate);
this.stopNotificationCheck();
}
handleAuthChange(data) {
this.render();
if (data.isLoggedIn) {
this.startNotificationCheck();
} else {
this.stopNotificationCheck();
}
}
handleNavigate(view) {
// Handle navigation updates if needed
}
startNotificationCheck() {
this.checkNotifications();
this.checkNotificationInterval = setInterval(() => {
this.checkNotifications();
}, 60000);
}
stopNotificationCheck() {
if (this.checkNotificationInterval) {
clearInterval(this.checkNotificationInterval);
this.checkNotificationInterval = null;
}
}
async checkNotifications() {
if (!authManager.isLoggedIn()) return;
const params = new URLSearchParams({
ext_prof: 1,
last_time: Math.floor(Date.now() / 1000) - 86400
});
const data = await authManager.apiCall(`/users/me/notif-feed?${params}`);
if (data.success) {
this.notificationCount = data.data.num_unread;
this.updateNotificationCount();
}
}
updateNotificationCount() {
const notifCount = this.querySelector('#notifCount');
if (notifCount) {
if (this.notificationCount > 0) {
notifCount.textContent = `(${this.notificationCount})`;
notifCount.style.color = 'var(--error)';
} else {
notifCount.textContent = '';
}
}
}
render() {
const isLoggedIn = authManager.isLoggedIn();
this.innerHTML = `
<nav>
<div class="nav-container">
<a href="#" class="logo">Rant</a>
<div class="nav-links">
<a href="#" data-view="feed">Feed</a>
<a href="#" data-view="search">Search</a>
${isLoggedIn ? `
<span>
<a href="#" data-view="profile">Profile</a>
<a href="#" data-view="notifications">Notifications <span id="notifCount"></span></a>
</span>
<span>
<button class="btn btn-secondary" id="logoutBtn">Logout</button>
</span>
` : `
<span>
<button class="btn btn-secondary" data-modal="login">Login</button>
<button class="btn" data-modal="register">Sign Up</button>
</span>
`}
</div>
</div>
</nav>
`;
// Add event listeners
this.querySelectorAll('[data-view]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = e.target.dataset.view;
eventBus.emit('navigate', view);
});
});
this.querySelectorAll('[data-modal]').forEach(btn => {
btn.addEventListener('click', (e) => {
const modalType = e.target.dataset.modal;
eventBus.emit('show-modal', modalType);
});
});
const logoutBtn = this.querySelector('#logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
authManager.logout();
eventBus.emit('navigate', 'feed');
});
}
const logo = this.querySelector('.logo');
logo.addEventListener('click', (e) => {
e.preventDefault();
eventBus.emit('navigate', 'feed');
});
this.updateNotificationCount();
}
}
// Rant Card Component
class RantCard extends HTMLElement {
static get observedAttributes() {
return ['rant-data', 'clickable'];
}
constructor() {
super();
this.rantData = null;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'rant-data' && newValue) {
this.rantData = JSON.parse(newValue);
this.render();
}
}
connectedCallback() {
if (this.rantData) {
this.render();
}
}
render() {
if (!this.rantData) return;
const rant = this.rantData;
const tags = rant.tags.map(tag => `<span class="tag">${tag}</span>`).join('');
const voteClass = rant.vote_state === 1 ? 'voted' : rant.vote_state === -1 ? 'downvoted' : '';
const clickable = this.getAttribute('clickable') !== 'false';
this.innerHTML = `
<div class="rant-card" ${clickable ? `style="cursor: pointer;"` : ''}>
<div class="rant-header">
<div class="avatar" style="background: #${rant.user_avatar.b}">
${rant.user_username[0].toUpperCase()}
</div>
<div class="user-info">
<div class="username" data-user-id="${rant.user_id}">${rant.user_username}</div>
<div class="score">${rant.user_score} points</div>
</div>
</div>
<div class="rant-content">${escapeHtml(rant.text)}</div>
${rant.attached_image ? `<img src="${rant.attached_image}" alt="Rant image" class="rant-image">` : ''}
${tags ? `<div class="tags">${tags}</div>` : ''}
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn ${voteClass}" data-action="vote" data-vote="${rant.vote_state === 1 ? 0 : 1}">
++ ${rant.score}
</button>
<button class="action-btn" data-action="comments">
💬 ${rant.num_comments}
</button>
</div>
<div>${formatTime(rant.created_time)}</div>
</div>
</div>
`;
// Add event listeners
const card = this.querySelector('.rant-card');
if (clickable) {
card.addEventListener('click', (e) => {
if (!e.target.closest('button') && !e.target.closest('.username')) {
eventBus.emit('navigate', { view: 'rant', id: rant.id });
}
});
}
this.querySelector('.username').addEventListener('click', (e) => {
e.stopPropagation();
eventBus.emit('navigate', { view: 'profile', id: rant.user_id });
});
this.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
if (action === 'vote') {
if (!authManager.isLoggedIn()) {
eventBus.emit('show-modal', 'login');
return;
}
const vote = parseInt(btn.dataset.vote);
await this.voteRant(rant.id, vote);
} else if (action === 'comments') {
eventBus.emit('navigate', { view: 'rant', id: rant.id });
}
});
});
}
async voteRant(rantId, vote) {
const formData = new FormData();
formData.append('vote', vote);
if (vote === -1) {
formData.append('reason', 0);
}
const data = await authManager.apiCall(`/rant/rants/${rantId}/vote`, {
method: 'POST',
body: formData
});
if (data.success) {
eventBus.emit('refresh-view');
}
}
}
// Loading Spinner Component
class LoadingSpinner extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<p>${this.getAttribute('message') || 'Loading...'}</p>
</div>
`;
}
}
// Sort Options Component
class SortOptions extends HTMLElement {
static get observedAttributes() {
return ['active-sort'];
}
constructor() {
super();
this.activeSort = 'recent';
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'active-sort') {
this.activeSort = newValue;
this.render();
}
}
connectedCallback() {
this.render();
}
render() {
this.innerHTML = `
<div class="sort-options">
<button class="sort-btn ${this.activeSort === 'recent' ? 'active' : ''}" data-sort="recent">Recent</button>
<button class="sort-btn ${this.activeSort === 'top' ? 'active' : ''}" data-sort="top">Top</button>
<button class="sort-btn ${this.activeSort === 'algo' ? 'active' : ''}" data-sort="algo">Algorithm</button>
</div>
`;
this.querySelectorAll('[data-sort]').forEach(btn => {
btn.addEventListener('click', () => {
const sort = btn.dataset.sort;
eventBus.emit('sort-changed', sort);
});
});
}
}
// Feed View Component
class FeedView extends HTMLElement {
constructor() {
super();
this.currentSort = 'recent';
// Bind methods to ensure proper reference for event removal
this.handleSortChange = this.handleSortChange.bind(this);
this.handleRefreshView = this.handleRefreshView.bind(this);
}
async connectedCallback() {
this.innerHTML = `
<sort-options active-sort="${this.currentSort}"></sort-options>
<loading-spinner message="Loading rants..."></loading-spinner>
`;
eventBus.on('sort-changed', this.handleSortChange);
eventBus.on('refresh-view', this.handleRefreshView);
await this.loadFeed();
}
disconnectedCallback() {
eventBus.off('sort-changed', this.handleSortChange);
eventBus.off('refresh-view', this.handleRefreshView);
}
handleSortChange(sort) {
this.currentSort = sort;
this.loadFeed();
}
handleRefreshView() {
this.loadFeed();
}
async loadFeed() {
const params = new URLSearchParams({
sort: this.currentSort,
limit: 50,
skip: 0,
app: authManager.APP_ID
});
const data = await authManager.apiCall(`/rant/rants?${params}`);
if (data.success) {
this.innerHTML = `
<sort-options active-sort="${this.currentSort}"></sort-options>
`;
const container = document.createElement('div');
data.rants.forEach(rant => {
const rantCard = document.createElement('rant-card');
rantCard.setAttribute('rant-data', JSON.stringify(rant));
container.appendChild(rantCard);
});
this.appendChild(container);
}
}
}
// Rant Detail View Component
class RantDetailView extends HTMLElement {
static get observedAttributes() {
return ['rant-id'];
}
constructor() {
super();
this.rantId = null;
this.rantData = null;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'rant-id') {
this.rantId = newValue;
this.loadRant();
}
}
async loadRant() {
if (!this.rantId) return;
this.innerHTML = `<loading-spinner message="Loading rant..."></loading-spinner>`;
const params = new URLSearchParams({ app: authManager.APP_ID });
const data = await authManager.apiCall(`/rant/rants/${this.rantId}?${params}`);
if (data.success) {
this.rantData = data;
this.render();
}
}
render() {
const { rant, comments, subscribed } = this.rantData;
const tags = rant.tags.map(tag => `<span class="tag">${tag}</span>`).join('');
const voteClass = rant.vote_state === 1 ? 'voted' : rant.vote_state === -1 ? 'downvoted' : '';
const isOwner = authManager.currentUser && authManager.currentUser.id === rant.user_id;
this.innerHTML = `
<button class="btn btn-secondary" id="backBtn">← Back to Feed</button>
<div class="rant-card" style="margin-top: 1rem;">
<div class="rant-header">
<div class="avatar" style="background: #${rant.user_avatar.b}">
${rant.user_username[0].toUpperCase()}
</div>
<div class="user-info">
<div class="username" data-user-id="${rant.user_id}" style="cursor: pointer;">${rant.user_username}</div>
<div class="score">${rant.user_score} points</div>
</div>
${isOwner ? `
<button class="btn btn-secondary" data-action="edit">Edit</button>
<button class="btn btn-secondary" data-action="delete" style="margin-left: 0.5rem;">Delete</button>
` : ''}
</div>
<div class="rant-content">${escapeHtml(rant.text)}</div>
${rant.attached_image ? `<img src="${rant.attached_image}" alt="Rant image" class="rant-image">` : ''}
${tags ? `<div class="tags">${tags}</div>` : ''}
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn ${voteClass}" data-action="vote" data-vote="${rant.vote_state === 1 ? 0 : 1}">
++ ${rant.score}
</button>
<button class="action-btn ${rant.vote_state === -1 ? 'downvoted' : ''}" data-action="vote" data-vote="${rant.vote_state === -1 ? 0 : -1}">
--
</button>
<button class="action-btn ${subscribed ? 'voted' : ''}" data-action="favorite">
${subscribed ? '★' : '☆'} Favorite
</button>
</div>
<div>${formatTime(rant.created_time)}</div>
</div>
</div>
<comments-section rant-id="${this.rantId}" comments='${JSON.stringify(comments)}'></comments-section>
`;
// Add event listeners
this.querySelector('#backBtn').addEventListener('click', () => {
eventBus.emit('navigate', 'feed');
});
this.querySelector('.username').addEventListener('click', () => {
eventBus.emit('navigate', { view: 'profile', id: rant.user_id });
});
this.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
if (action === 'vote') {
if (!authManager.isLoggedIn()) {
eventBus.emit('show-modal', 'login');
return;
}
const vote = parseInt(btn.dataset.vote);
await this.voteRant(vote);
} else if (action === 'favorite') {
if (!authManager.isLoggedIn()) {
eventBus.emit('show-modal', 'login');
return;
}
await this.toggleFavorite();
} else if (action === 'edit') {
eventBus.emit('show-modal', { type: 'edit-rant', rant });
} else if (action === 'delete') {
await this.deleteRant();
}
});
});
}
async voteRant(vote) {
const formData = new FormData();
formData.append('vote', vote);
if (vote === -1) {
formData.append('reason', 0);
}
const data = await authManager.apiCall(`/rant/rants/${this.rantId}/vote`, {
method: 'POST',
body: formData
});
if (data.success) {
this.loadRant();
}
}
async toggleFavorite() {
const endpoint = this.rantData.subscribed ? 'unfavorite' : 'favorite';
const formData = new FormData();
const data = await authManager.apiCall(`/rant/rants/${this.rantId}/${endpoint}`, {
method: 'POST',
body: formData
});
if (data.success) {
this.loadRant();
}
}
async deleteRant() {
if (!confirm('Are you sure you want to delete this rant?')) return;
const params = new URLSearchParams();
params.append('app', authManager.APP_ID);
params.append('token_id', authManager.currentUser.token_id);
params.append('token_key', authManager.currentUser.token_key);
params.append('user_id', authManager.currentUser.id);
const data = await authManager.apiCall(`/rant/rants/${this.rantId}?${params}`, {
method: 'DELETE'
});
if (data.success) {
eventBus.emit('navigate', 'feed');
}
}
}
// Comments Section Component
class CommentsSection extends HTMLElement {
static get observedAttributes() {
return ['rant-id', 'comments'];
}
constructor() {
super();
this.rantId = null;
this.comments = [];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'rant-id') {
this.rantId = newValue;
} else if (name === 'comments') {
this.comments = JSON.parse(newValue);
this.render();
}
}
render() {
this.innerHTML = `
<div class="comments-section">
<h3>Comments (${this.comments.length})</h3>
<div id="commentsList">
${this.comments.map(comment => this.createCommentHTML(comment)).join('')}
</div>
${authManager.isLoggedIn() ? `
<form class="comment-form" id="commentForm">
<h4>Add a comment</h4>
<div class="form-group">
<textarea name="comment" placeholder="Write your comment..." required></textarea>
</div>
<button type="submit" class="btn">Post Comment</button>
</form>
` : '<p style="text-align: center; margin-top: 2rem;">Login to comment</p>'}
</div>
`;
// Add event listeners
const form = this.querySelector('#commentForm');
if (form) {
form.addEventListener('submit', (e) => this.postComment(e));
}
this.querySelectorAll('[data-comment-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.commentAction;
const commentId = btn.dataset.commentId;
if (action === 'vote') {
const vote = parseInt(btn.dataset.vote);
await this.voteComment(commentId, vote);
} else if (action === 'delete') {
await this.deleteComment(commentId);
}
});
});
this.querySelectorAll('.username').forEach(username => {
username.addEventListener('click', () => {
const userId = username.dataset.userId;
eventBus.emit('navigate', { view: 'profile', id: parseInt(userId) });
});
});
}
createCommentHTML(comment) {
const voteClass = comment.vote_state === 1 ? 'voted' : comment.vote_state === -1 ? 'downvoted' : '';
const isOwner = authManager.currentUser && authManager.currentUser.id === comment.user_id;
return `
<div class="comment">
<div class="rant-header">
<div class="avatar" style="background: #${comment.user_avatar.b}">
${comment.user_username[0].toUpperCase()}
</div>
<div class="user-info">
<div class="username" data-user-id="${comment.user_id}" style="cursor: pointer;">${comment.user_username}</div>
<div class="score">${comment.user_score} points</div>
</div>
${isOwner ? `
<button class="btn btn-secondary" data-comment-action="delete" data-comment-id="${comment.id}">Delete</button>
` : ''}
</div>
<div class="rant-content">${escapeHtml(comment.body)}</div>
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn ${voteClass}" data-comment-action="vote" data-comment-id="${comment.id}" data-vote="${comment.vote_state === 1 ? 0 : 1}">
++ ${comment.score}
</button>
</div>
<div>${formatTime(comment.created_time)}</div>
</div>
</div>
`;
}
async postComment(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = await authManager.apiCall(`/rant/rants/${this.rantId}/comments`, {
method: 'POST',
body: formData
});
if (data.success) {
eventBus.emit('refresh-view');
}
}
async voteComment(commentId, vote) {
if (!authManager.isLoggedIn()) {
eventBus.emit('show-modal', 'login');
return;
}
const formData = new FormData();
formData.append('vote', vote);
const data = await authManager.apiCall(`/comments/${commentId}/vote`, {
method: 'POST',
body: formData
});
if (data.success) {
eventBus.emit('refresh-view');
}
}
async deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment?')) return;
const params = new URLSearchParams();
params.append('app', authManager.APP_ID);
params.append('token_id', authManager.currentUser.token_id);
params.append('token_key', authManager.currentUser.token_key);
params.append('user_id', authManager.currentUser.id);
const data = await authManager.apiCall(`/comments/${commentId}?${params}`, {
method: 'DELETE'
});
if (data.success) {
eventBus.emit('refresh-view');
}
}
}
// Profile View Component
class ProfileView extends HTMLElement {
static get observedAttributes() {
return ['user-id'];
}
constructor() {
super();
this.userId = null;
this.profileData = null;
this.activeTab = 'rants';
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'user-id') {
this.userId = newValue;
this.loadProfile();
}
}
async loadProfile() {
if (!this.userId) return;
this.innerHTML = `<loading-spinner message="Loading profile..."></loading-spinner>`;
const params = new URLSearchParams({ app: authManager.APP_ID });
const data = await authManager.apiCall(`/users/${this.userId}?${params}`);
if (data.success) {
this.profileData = data.profile;
this.render();
}
}
render() {
const profile = this.profileData;
const isOwnProfile = authManager.currentUser && authManager.currentUser.id === parseInt(this.userId);
this.innerHTML = `
<button class="btn btn-secondary" id="backBtn">← Back to Feed</button>
<div class="profile-header">
<div class="profile-avatar avatar" style="background: #${profile.avatar.b}">
${profile.username[0].toUpperCase()}
</div>
<h1>${profile.username}</h1>
${profile.about ? `<p style="margin-top: 1rem;">${escapeHtml(profile.about)}</p>` : ''}
${profile.skills ? `<p><strong>Skills:</strong> ${escapeHtml(profile.skills)}</p>` : ''}
${profile.location ? `<p><strong>Location:</strong> ${escapeHtml(profile.location)}</p>` : ''}
${profile.github ? `<p><strong>GitHub:</strong> <a href="https://github.com/${profile.github}" target="_blank">${profile.github}</a></p>` : ''}
${profile.website ? `<p><strong>Website:</strong> <a href="${profile.website}" target="_blank">${profile.website}</a></p>` : ''}
<div class="profile-stats">
<div class="stat">
<div class="stat-value">${profile.score}</div>
<div class="stat-label">Score</div>
</div>
<div class="stat">
<div class="stat-value">${profile.content.content.rants.length}</div>
<div class="stat-label">Rants</div>
</div>
<div class="stat">
<div class="stat-value">${profile.content.content.comments.length}</div>
<div class="stat-label">Comments</div>
</div>
</div>
${isOwnProfile ? `<button class="btn" id="editProfileBtn" style="margin-top: 1rem;">Edit Profile</button>` : ''}
</div>
<div class="tabs">
<button class="tab ${this.activeTab === 'rants' ? 'active' : ''}" data-tab="rants">Rants</button>
<button class="tab ${this.activeTab === 'comments' ? 'active' : ''}" data-tab="comments">Comments</button>
<button class="tab ${this.activeTab === 'favorites' ? 'active' : ''}" data-tab="favorites">Favorites</button>
</div>
<div id="profileContent">
${this.renderTabContent()}
</div>
`;
// Add event listeners
this.querySelector('#backBtn').addEventListener('click', () => {
eventBus.emit('navigate', 'feed');
});
const editBtn = this.querySelector('#editProfileBtn');
if (editBtn) {
editBtn.addEventListener('click', () => {
eventBus.emit('show-modal', 'edit-profile');
});
}
this.querySelectorAll('[data-tab]').forEach(tab => {
tab.addEventListener('click', () => {
this.activeTab = tab.dataset.tab;
this.render();
});
});
}
renderTabContent() {
const profile = this.profileData;
if (this.activeTab === 'rants') {
return profile.content.content.rants.map(rant => {
const card = document.createElement('rant-card');
card.setAttribute('rant-data', JSON.stringify(rant));
return card.outerHTML;
}).join('');
} else if (this.activeTab === 'comments') {
return profile.content.content.comments.map(comment => `
<div class="rant-card" style="cursor: pointer;" data-rant-id="${comment.rant_id}">
<div class="rant-content">${escapeHtml(comment.body)}</div>
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn">++ ${comment.score}</button>
</div>
<div>${formatTime(comment.created_time)}</div>
</div>
</div>
`).join('');
} else if (this.activeTab === 'favorites') {
return profile.content.content.favorites.map(rant => {
const card = document.createElement('rant-card');
card.setAttribute('rant-data', JSON.stringify(rant));
return card.outerHTML;
}).join('');
}
}
}
// Search View Component
class SearchView extends HTMLElement {
connectedCallback() {
this.render();
}
render() {
this.innerHTML = `
<div class="search-box">
<form class="search-form" id="searchForm">
<input type="text" name="term" placeholder="Search rants..." required>
<button type="submit" class="btn">Search</button>
</form>
</div>
<div id="searchResults"></div>
`;
this.querySelector('#searchForm').addEventListener('submit', (e) => this.search(e));
}
async search(event) {
event.preventDefault();
const term = event.target.term.value;
const resultsDiv = this.querySelector('#searchResults');
resultsDiv.innerHTML = `<loading-spinner message="Searching..."></loading-spinner>`;
const params = new URLSearchParams({ term, app: authManager.APP_ID });
const data = await authManager.apiCall(`/rant/search?${params}`);
if (data.success) {
if (data.results.length === 0) {
resultsDiv.innerHTML = '<p style="text-align: center; color: var(--text-dim);">No results found</p>';
} else {
resultsDiv.innerHTML = '';
data.results.forEach(rant => {
const rantCard = document.createElement('rant-card');
rantCard.setAttribute('rant-data', JSON.stringify(rant));
resultsDiv.appendChild(rantCard);
});
}
}
}
}
// Notifications View Component
class NotificationsView extends HTMLElement {
async connectedCallback() {
this.innerHTML = `<loading-spinner message="Loading notifications..."></loading-spinner>`;
await this.loadNotifications();
}
async loadNotifications() {
const params = new URLSearchParams({
ext_prof: 1,
last_time: Math.floor(Date.now() / 1000) - 86400
});
const data = await authManager.apiCall(`/users/me/notif-feed?${params}`);
if (data.success) {
const items = data.data.items;
this.innerHTML = `
<h2>Notifications</h2>
${items.length === 0 ? '<p style="text-align: center; color: var(--text-dim); margin-top: 2rem;">No notifications</p>' : ''}
${items.map(notif => `
<div class="rant-card" data-rant-id="${notif.rant_id}" style="cursor: pointer;">
<p><strong>${notif.username}</strong> ${notif.type === 'comment' ? 'commented on your rant' : 'mentioned you'}</p>
<p style="color: var(--text-dim); font-size: 0.9rem;">${formatTime(notif.created_time)}</p>
</div>
`).join('')}
`;
this.querySelectorAll('[data-rant-id]').forEach(card => {
card.addEventListener('click', () => {
const rantId = card.dataset.rantId;
eventBus.emit('navigate', { view: 'rant', id: rantId });
});
});
// Clear notification count
eventBus.emit('notifications-read');
} else {
this.innerHTML = `
<h2>Notifications</h2>
<p style="text-align: center; color: var(--error); margin-top: 2rem;">Failed to load notifications</p>
`;
}
}
}
// Modal Component
class RantModal extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Modal Title</h2>
<button class="close-btn">&times;</button>
</div>
<div id="modalBody">
<!-- Modal content will be loaded here -->
</div>
</div>
</div>
`;
this.querySelector('.close-btn').addEventListener('click', () => this.close());
this.querySelector('#modal').addEventListener('click', (e) => {
if (e.target.id === 'modal') {
this.close();
}
});
eventBus.on('show-modal', (type) => this.show(type));
}
show(modalData) {
const modalType = typeof modalData === 'string' ? modalData : modalData.type;
switch (modalType) {
case 'login':
this.showLogin();
break;
case 'register':
this.showRegister();
break;
case 'create-rant':
this.showCreateRant();
break;
case 'edit-profile':
this.showEditProfile();
break;
case 'edit-rant':
this.showEditRant(modalData.rant);
break;
}
}
close() {
this.querySelector('#modal').classList.remove('active');
}
setContent(title, content) {
this.querySelector('#modalTitle').textContent = title;
this.querySelector('#modalBody').innerHTML = content;
this.querySelector('#modal').classList.add('active');
}
showLogin() {
this.setContent('Login', `
<form id="loginForm">
<div class="alert error" id="loginError"></div>
<div class="form-group">
<label>Username or Email</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">Login</button>
<p style="text-align: center; margin-top: 1rem;">
Don't have an account? <a href="#" id="showRegisterLink">Sign up</a>
</p>
</form>
`);
this.querySelector('#loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = await authManager.login(formData.get('username'), formData.get('password'));
if (data.success) {
this.close();
eventBus.emit('navigate', 'feed');
} else {
this.showError('loginError', data.error);
}
});
this.querySelector('#showRegisterLink').addEventListener('click', (e) => {
e.preventDefault();
this.showRegister();
});
}
showRegister() {
this.setContent('Sign Up', `
<form id="registerForm">
<div class="alert error" id="registerError"></div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required minlength="4" maxlength="15">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">Sign Up</button>
<p style="text-align: center; margin-top: 1rem;">
Already have an account? <a href="#" id="showLoginLink">Login</a>
</p>
</form>
`);
this.querySelector('#registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
formData.append('app', authManager.APP_ID);
formData.append('type', 1);
const data = await authManager.apiCall('/users', {
method: 'POST',
body: formData
});
if (data.success) {
this.close();
this.showLogin();
alert('Registration successful! Please login.');
} else {
this.showError('registerError', data.error);
}
});
this.querySelector('#showLoginLink').addEventListener('click', (e) => {
e.preventDefault();
this.showLogin();
});
}
showCreateRant() {
if (!authManager.isLoggedIn()) {
this.showLogin();
return;
}
this.setContent('Create Rant', `
<form id="createRantForm">
<div class="alert error" id="rantError"></div>
<div class="form-group">
<label>What's on your mind?</label>
<textarea name="rant" placeholder="Share your thoughts..." required></textarea>
</div>
<div class="form-group">
<label>Tags (comma separated)</label>
<input type="text" name="tags" placeholder="rant, javascript, devops">
</div>
<div class="form-group">
<label>Type</label>
<select name="type">
<option value="1">Rant</option>
<option value="2">Collab</option>
<option value="3">Question</option>
<option value="4">devRant</option>
<option value="5">Random</option>
</select>
</div>
<button type="submit" class="btn" style="width: 100%;">Post Rant</button>
</form>
`);
this.querySelector('#createRantForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = await authManager.apiCall('/rant/rants', {
method: 'POST',
body: formData
});
if (data.success) {
this.close();
eventBus.emit('navigate', { view: 'rant', id: data.rant_id });
} else {
this.showError('rantError', data.error);
}
});
}
showEditProfile() {
this.setContent('Edit Profile', `
<form id="editProfileForm">
<div class="alert success" id="profileSuccess"></div>
<div class="form-group">
<label>About</label>
<textarea name="about" placeholder="Tell us about yourself..."></textarea>
</div>
<div class="form-group">
<label>Skills</label>
<input type="text" name="skills" placeholder="JavaScript, Python, DevOps">
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" placeholder="San Francisco, CA">
</div>
<div class="form-group">
<label>Website</label>
<input type="url" name="website" placeholder="https://example.com">
</div>
<div class="form-group">
<label>GitHub Username</label>
<input type="text" name="github" placeholder="username">
</div>
<button type="submit" class="btn" style="width: 100%;">Update Profile</button>
</form>
`);
this.querySelector('#editProfileForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
// Add profile_ prefix to all fields
const profileData = new FormData();
for (let [key, value] of formData.entries()) {
profileData.append(`profile_${key}`, value);
}
const data = await authManager.apiCall('/users/me/edit-profile', {
method: 'POST',
body: profileData
});
if (data.success) {
this.showSuccess('profileSuccess', 'Profile updated successfully!');
setTimeout(() => {
this.close();
eventBus.emit('navigate', { view: 'profile', id: authManager.currentUser.id });
}, 1500);
}
});
}
showError(elementId, message) {
const errorEl = this.querySelector(`#${elementId}`);
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.add('active');
}
}
showSuccess(elementId, message) {
const successEl = this.querySelector(`#${elementId}`);
if (successEl) {
successEl.textContent = message;
successEl.classList.add('active');
}
}
}
// Floating Action Button Component
class RantFab extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button class="fab" id="createRantBtn" style="display: none;">+</button>
`;
this.querySelector('#createRantBtn').addEventListener('click', () => {
eventBus.emit('show-modal', 'create-rant');
});
eventBus.on('auth-changed', (data) => {
this.querySelector('#createRantBtn').style.display = data.isLoggedIn ? 'flex' : 'none';
});
// Initial check
if (authManager.isLoggedIn()) {
this.querySelector('#createRantBtn').style.display = 'flex';
}
}
}
// Main App Component
class RantApp extends HTMLElement {
constructor() {
super();
this.currentViewData = 'feed';
}
connectedCallback() {
eventBus.on('navigate', (viewData) => this.navigate(viewData));
eventBus.on('refresh-view', () => this.refreshView());
// Initial navigation
this.navigate('feed');
}
navigate(viewData) {
const view = typeof viewData === 'string' ? viewData : viewData.view;
const params = typeof viewData === 'object' ? viewData : {};
this.currentViewData = viewData;
const content = document.getElementById('content');
switch (view) {
case 'feed':
content.innerHTML = '<feed-view></feed-view>';
break;
case 'rant':
content.innerHTML = `<rant-detail-view rant-id="${params.id}"></rant-detail-view>`;
break;
case 'profile':
const userId = params.id || authManager.currentUser?.id;
content.innerHTML = `<profile-view user-id="${userId}"></profile-view>`;
break;
case 'search':
content.innerHTML = '<search-view></search-view>';
break;
case 'notifications':
if (authManager.isLoggedIn()) {
content.innerHTML = '<notifications-view></notifications-view>';
} else {
eventBus.emit('show-modal', 'login');
}
break;
}
}
refreshView() {
this.navigate(this.currentViewData);
}
}
// Register all custom elements
customElements.define('rant-navigation', RantNavigation);
customElements.define('rant-card', RantCard);
customElements.define('loading-spinner', LoadingSpinner);
customElements.define('sort-options', SortOptions);
customElements.define('feed-view', FeedView);
customElements.define('rant-detail-view', RantDetailView);
customElements.define('comments-section', CommentsSection);
customElements.define('profile-view', ProfileView);
customElements.define('search-view', SearchView);
customElements.define('notifications-view', NotificationsView);
customElements.define('rant-modal', RantModal);
customElements.define('rant-fab', RantFab);
customElements.define('rant-app', RantApp);
// Initialize the app
const app = document.createElement('rant-app');
document.body.appendChild(app);
</script>
</body>
</html>