Files
devrant/static/index.html
T
retoor 2eefaacc56 feat: replace static navigation HTML with custom rant-navigation web component
The static navigation markup in index.html is replaced by a single <rant-navigation> custom element, enabling dynamic rendering of login, profile, and logout controls. The floating action button and modal containers are also moved into the component's shadow DOM for encapsulated styling and behavior.
2025-08-04 15:14:28 +00:00

1973 lines
72 KiB
HTML

<!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';
}
async connectedCallback() {
this.innerHTML = `
<sort-options active-sort="${this.currentSort}"></sort-options>
<loading-spinner message="Loading rants..."></loading-spinner>
`;
eventBus.on('sort-changed', (sort) => this.handleSortChange(sort));
eventBus.on('refresh-view', () => this.loadFeed());
await this.loadFeed();
}
disconnectedCallback() {
eventBus.off('sort-changed', this.handleSortChange);
eventBus.off('refresh-view', this.loadFeed);
}
handleSortChange(sort) {
this.currentSort = sort;
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);
// Re-add event listener after re-render
eventBus.on('sort-changed', (sort) => this.handleSortChange(sort));
}
}
}
// 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>