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.
1973 lines
72 KiB
HTML
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">×</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>
|