Initial commit.

This commit is contained in:
2025-12-04 20:29:35 +01:00
commit ae789a5b40
87 changed files with 9798 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
/**
* @fileoverview Application Header Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Main navigation header with search and user controls
* @keywords header, navigation, search, menu, toolbar
*/
import { BaseComponent } from './base-component.js';
class AppHeader extends BaseComponent {
static get observedAttributes() {
return ['logged-in'];
}
init() {
this.render();
this.bindEvents();
}
render() {
const isLoggedIn = this.isLoggedIn();
const user = this.getCurrentUser();
this.setHtml(`
<div class="header-container">
<div class="header-left">
<button class="menu-toggle" aria-label="Toggle menu">
<span class="menu-icon"></span>
</button>
<a href="?" class="logo">
<span class="logo-text">Rantii</span>
</a>
</div>
<div class="header-center">
<div class="search-container">
<input type="search" class="search-input" placeholder="Search rants..." aria-label="Search">
<button class="search-btn" aria-label="Search">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.505 6.505 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</button>
</div>
</div>
<div class="header-right">
${isLoggedIn ? `
<button class="header-btn notifications-btn" aria-label="Notifications">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/>
</svg>
<span class="notification-badge" hidden>0</span>
</button>
<button class="header-btn post-btn" aria-label="Create post">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</button>
<button class="header-btn user-btn" aria-label="User menu">
<user-avatar size="small" username="${user?.username || ''}"></user-avatar>
</button>
` : `
<button class="header-btn login-btn">Login</button>
`}
<button class="header-btn theme-btn" aria-label="Toggle theme">
<svg viewBox="0 0 24 24" width="24" height="24" class="theme-icon-dark">
<path fill="currentColor" d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/>
</svg>
<svg viewBox="0 0 24 24" width="24" height="24" class="theme-icon-light">
<path fill="currentColor" d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg>
</button>
</div>
</div>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'submit', this.handleSubmit);
const searchInput = this.$('.search-input');
if (searchInput) {
this.on(searchInput, 'keydown', this.handleSearchKeydown);
}
window.addEventListener('rantii:auth-change', () => this.render());
}
handleClick(e) {
const target = e.target.closest('button, a');
if (!target) return;
if (target.classList.contains('menu-toggle')) {
e.preventDefault();
this.emit('menu-toggle');
} else if (target.classList.contains('logo') || target.closest('.logo')) {
e.preventDefault();
this.getRouter()?.goHome();
} else if (target.classList.contains('search-btn')) {
this.performSearch();
} else if (target.classList.contains('notifications-btn')) {
this.getRouter()?.goToNotifications();
} else if (target.classList.contains('post-btn')) {
this.emit('create-post');
} else if (target.classList.contains('user-btn')) {
this.emit('user-menu');
} else if (target.classList.contains('login-btn')) {
this.getRouter()?.goToLogin();
} else if (target.classList.contains('theme-btn')) {
this.getTheme()?.toggleDarkLight();
}
}
handleSearchKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.performSearch();
}
}
handleSubmit(e) {
e.preventDefault();
}
performSearch() {
const input = this.$('.search-input');
if (input && input.value.trim()) {
this.getRouter()?.goToSearch(input.value.trim());
}
}
setNotificationCount(count) {
const badge = this.$('.notification-badge');
if (badge) {
badge.textContent = count > 99 ? '99+' : count;
badge.hidden = count === 0;
}
}
clearSearch() {
const input = this.$('.search-input');
if (input) {
input.value = '';
}
}
}
customElements.define('app-header', AppHeader);
export { AppHeader };
+207
View File
@@ -0,0 +1,207 @@
/**
* @fileoverview Application Navigation Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Side navigation menu with main app sections
* @keywords navigation, menu, sidebar, nav, links
*/
import { BaseComponent } from './base-component.js';
class AppNav extends BaseComponent {
static get observedAttributes() {
return ['active', 'expanded'];
}
init() {
this.render();
this.bindEvents();
}
render() {
const active = this.getAttr('active') || 'home';
const isLoggedIn = this.isLoggedIn();
this.setHtml(`
<nav class="nav-container">
<ul class="nav-list">
<li class="nav-item ${active === 'home' ? 'active' : ''}">
<a href="?" class="nav-link" data-route="home">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
<span class="nav-label">Home</span>
</a>
</li>
<li class="nav-item ${active === 'weekly' ? 'active' : ''}">
<a href="?weekly" class="nav-link" data-route="weekly">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
<span class="nav-label">Weekly</span>
</a>
</li>
<li class="nav-item ${active === 'collabs' ? 'active' : ''}">
<a href="?collabs" class="nav-link" data-route="collabs">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>
</svg>
<span class="nav-label">Collabs</span>
</a>
</li>
<li class="nav-item ${active === 'stories' ? 'active' : ''}">
<a href="?stories" class="nav-link" data-route="stories">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
</svg>
<span class="nav-label">Stories</span>
</a>
</li>
<li class="nav-item ${active === 'search' ? 'active' : ''}">
<a href="?search" class="nav-link" data-route="search">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.505 6.505 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
<span class="nav-label">Search</span>
</a>
</li>
${isLoggedIn ? `
<li class="nav-divider"></li>
<li class="nav-item ${active === 'notifications' ? 'active' : ''}">
<a href="?notifications" class="nav-link" data-route="notifications">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/>
</svg>
<span class="nav-label">Notifications</span>
<span class="nav-badge" hidden>0</span>
</a>
</li>
<li class="nav-item ${active === 'profile' ? 'active' : ''}">
<a href="?user=${this.getCurrentUser()?.username || ''}" class="nav-link" data-route="profile">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
<span class="nav-label">Profile</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link nav-link-danger" data-route="logout">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg>
<span class="nav-label">Logout</span>
</a>
</li>
` : `
<li class="nav-divider"></li>
<li class="nav-item">
<a href="?login" class="nav-link" data-route="login">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z"/>
</svg>
<span class="nav-label">Login</span>
</a>
</li>
`}
<li class="nav-divider"></li>
<li class="nav-item ${active === 'settings' ? 'active' : ''}">
<a href="?settings" class="nav-link" data-route="settings">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>
<span class="nav-label">Settings</span>
</a>
</li>
</ul>
</nav>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
window.addEventListener('rantii:auth-change', () => this.render());
window.addEventListener('rantii:route-change', (e) => {
this.setAttr('active', e.detail.route);
this.render();
});
}
handleClick(e) {
const link = e.target.closest('.nav-link');
if (!link) return;
e.preventDefault();
const route = link.dataset.route;
switch (route) {
case 'home':
this.getRouter()?.goHome();
break;
case 'weekly':
this.getRouter()?.goToWeekly();
break;
case 'collabs':
this.getRouter()?.goToCollabs();
break;
case 'stories':
this.getRouter()?.goToStories();
break;
case 'search':
this.getRouter()?.goToSearch();
break;
case 'notifications':
this.getRouter()?.goToNotifications();
break;
case 'profile':
const username = this.getCurrentUser()?.username;
if (username) {
this.getRouter()?.goToUser(username);
}
break;
case 'settings':
this.getRouter()?.goToSettings();
break;
case 'login':
this.getRouter()?.goToLogin();
break;
case 'logout':
this.getAuth()?.logout();
this.getRouter()?.goHome();
break;
}
this.emit('nav-click', { route });
}
setActive(route) {
this.setAttr('active', route);
this.render();
}
setNotificationBadge(count) {
const badge = this.$('.nav-badge');
if (badge) {
badge.textContent = count > 99 ? '99+' : count;
badge.hidden = count === 0;
}
}
expand() {
this.setAttr('expanded', '');
}
collapse() {
this.removeAttribute('expanded');
}
toggle() {
if (this.hasAttr('expanded')) {
this.collapse();
} else {
this.expand();
}
}
}
customElements.define('app-nav', AppNav);
export { AppNav };
+220
View File
@@ -0,0 +1,220 @@
/**
* @fileoverview Base Component Class for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Foundation class for all custom HTML elements
* @keywords component, web component, custom element, base, foundation
*/
class BaseComponent extends HTMLElement {
constructor() {
super();
this.isInitialized = false;
this.eventListeners = [];
}
connectedCallback() {
if (!this.isInitialized) {
this.init();
this.isInitialized = true;
}
this.onConnected();
}
disconnectedCallback() {
this.cleanup();
this.onDisconnected();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.onAttributeChanged(name, oldValue, newValue);
}
}
init() {}
onConnected() {}
onDisconnected() {}
onAttributeChanged(name, oldValue, newValue) {}
render() {}
update(data) {
Object.assign(this, data);
this.render();
}
$(selector) {
return this.querySelector(selector);
}
$$(selector) {
return this.querySelectorAll(selector);
}
on(target, event, handler, options = {}) {
const boundHandler = handler.bind(this);
target.addEventListener(event, boundHandler, options);
this.eventListeners.push({ target, event, handler: boundHandler, options });
return boundHandler;
}
off(target, event, handler) {
target.removeEventListener(event, handler);
this.eventListeners = this.eventListeners.filter(
l => !(l.target === target && l.event === event && l.handler === handler)
);
}
cleanup() {
this.eventListeners.forEach(({ target, event, handler, options }) => {
target.removeEventListener(event, handler, options);
});
this.eventListeners = [];
}
emit(eventName, detail = {}) {
const event = new CustomEvent(eventName, {
bubbles: true,
composed: true,
detail
});
this.dispatchEvent(event);
}
setHtml(html) {
this.innerHTML = html;
}
setText(text) {
this.textContent = text;
}
show() {
this.style.display = '';
this.removeAttribute('hidden');
}
hide() {
this.style.display = 'none';
}
toggle(visible) {
if (visible !== undefined) {
visible ? this.show() : this.hide();
} else {
this.style.display === 'none' ? this.show() : this.hide();
}
}
addClass(...classNames) {
this.classList.add(...classNames);
}
removeClass(...classNames) {
this.classList.remove(...classNames);
}
toggleClass(className, force) {
this.classList.toggle(className, force);
}
hasClass(className) {
return this.classList.contains(className);
}
setData(key, value) {
this.dataset[key] = value;
}
getData(key) {
return this.dataset[key];
}
getAttr(name) {
return this.getAttribute(name);
}
setAttr(name, value) {
if (value === null || value === undefined) {
this.removeAttribute(name);
} else {
this.setAttribute(name, value);
}
}
hasAttr(name) {
return this.hasAttribute(name);
}
async waitForInit() {
return new Promise(resolve => {
if (this.isInitialized) {
resolve();
} else {
const observer = new MutationObserver(() => {
if (this.isInitialized) {
observer.disconnect();
resolve();
}
});
observer.observe(this, { childList: true, subtree: true });
}
});
}
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
throttle(func, limit) {
let inThrottle;
return (...args) => {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
getApp() {
return window.app;
}
getApi() {
return window.app?.api;
}
getAuth() {
return window.app?.auth;
}
getRouter() {
return window.app?.router;
}
getStorage() {
return window.app?.storage;
}
getTheme() {
return window.app?.theme;
}
isLoggedIn() {
return window.app?.auth?.isLoggedIn() || false;
}
getCurrentUser() {
return window.app?.auth?.getUser() || null;
}
}
export { BaseComponent };
+180
View File
@@ -0,0 +1,180 @@
/**
* @fileoverview Comment Form Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Form for posting new comments
* @keywords comment, form, post, reply, input
*/
import { BaseComponent } from './base-component.js';
class CommentForm extends BaseComponent {
static get observedAttributes() {
return ['rant-id'];
}
init() {
this.isSubmitting = false;
this.render();
this.bindEvents();
this.loadDraft();
}
render() {
const rantId = this.getAttr('rant-id');
const isLoggedIn = this.isLoggedIn();
this.addClass('comment-form');
if (!isLoggedIn) {
this.setHtml(`
<div class="comment-form-auth">
<p>Sign in to comment</p>
<button class="btn btn-primary login-btn">Sign In</button>
</div>
`);
return;
}
this.setHtml(`
<form class="comment-form-inner">
<div class="form-group">
<textarea
class="comment-input"
placeholder="Write a comment..."
rows="3"
maxlength="5000"
${this.isSubmitting ? 'disabled' : ''}></textarea>
</div>
<div class="form-actions">
<span class="char-count">0 / 5000</span>
<button type="submit"
class="btn btn-primary submit-btn"
${this.isSubmitting ? 'disabled' : ''}>
${this.isSubmitting ? '<loading-spinner size="small"></loading-spinner>' : 'Post'}
</button>
</div>
</form>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'submit', this.handleSubmit);
this.on(this, 'input', this.handleInput);
}
handleClick(e) {
const loginBtn = e.target.closest('.login-btn');
if (loginBtn) {
this.getRouter()?.goToLogin();
}
}
async handleSubmit(e) {
e.preventDefault();
if (this.isSubmitting) return;
const textarea = this.$('.comment-input');
if (!textarea) return;
const text = textarea.value.trim();
if (!text) return;
const rantId = this.getAttr('rant-id');
if (!rantId) return;
this.isSubmitting = true;
this.render();
try {
const result = await this.getApi()?.postComment(rantId, text);
if (result?.success) {
this.clearDraft();
this.emit('comment-posted', { rantId, comment: result.comment });
this.isSubmitting = false;
this.render();
} else {
this.getApp()?.toast?.error(result?.error || 'Failed to post comment');
this.isSubmitting = false;
this.render();
}
} catch (error) {
this.getApp()?.toast?.error('Failed to post comment');
this.isSubmitting = false;
this.render();
}
}
handleInput(e) {
if (e.target.classList.contains('comment-input')) {
const textarea = e.target;
const count = textarea.value.length;
const countEl = this.$('.char-count');
if (countEl) {
countEl.textContent = `${count} / 5000`;
}
this.saveDraft(textarea.value);
}
}
loadDraft() {
const rantId = this.getAttr('rant-id');
if (rantId) {
const draft = this.getStorage()?.getDraftComment(rantId);
if (draft) {
const textarea = this.$('.comment-input');
if (textarea) {
textarea.value = draft;
const countEl = this.$('.char-count');
if (countEl) {
countEl.textContent = `${draft.length} / 5000`;
}
}
}
}
}
saveDraft(text) {
const rantId = this.getAttr('rant-id');
if (rantId) {
this.getStorage()?.setDraftComment(rantId, text);
}
}
clearDraft() {
const rantId = this.getAttr('rant-id');
if (rantId) {
this.getStorage()?.clearDraftComment(rantId);
}
}
reset() {
const textarea = this.$('.comment-input');
if (textarea) {
textarea.value = '';
}
const countEl = this.$('.char-count');
if (countEl) {
countEl.textContent = '0 / 5000';
}
this.clearDraft();
}
focus() {
const textarea = this.$('.comment-input');
if (textarea) {
textarea.focus();
}
}
onAttributeChanged(name, oldValue, newValue) {
if (name === 'rant-id') {
this.loadDraft();
}
}
}
customElements.define('comment-form', CommentForm);
export { CommentForm };
+232
View File
@@ -0,0 +1,232 @@
/**
* @fileoverview Comment Item Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Single comment display with voting
* @keywords comment, item, reply, discussion, vote
*/
import { BaseComponent } from './base-component.js';
import { formatRelativeTime } from '../utils/date.js';
class CommentItem extends BaseComponent {
static get observedAttributes() {
return ['comment-id'];
}
init() {
this.commentData = null;
this.isEditing = false;
this.render();
this.bindEvents();
}
setComment(comment) {
this.commentData = comment;
this.setAttr('comment-id', comment.id);
this.render();
}
render() {
if (!this.commentData) {
this.setHtml('<div class="comment-skeleton"></div>');
return;
}
const comment = this.commentData;
const isOwner = this.getCurrentUser()?.id === comment.user_id;
this.addClass('comment-item');
if (this.isEditing) {
this.setHtml(`
<div class="comment-edit">
<textarea class="comment-edit-input">${comment.body}</textarea>
<div class="comment-edit-actions">
<button class="btn btn-secondary cancel-edit-btn">Cancel</button>
<button class="btn btn-primary save-edit-btn">Save</button>
</div>
</div>
`);
return;
}
this.setHtml(`
<article class="comment-content">
<header class="comment-header">
<user-avatar
avatar='${JSON.stringify(comment.user_avatar)}'
username="${comment.user_username}"
size="small">
</user-avatar>
<div class="comment-meta">
<span class="comment-username">${comment.user_username}</span>
<span class="comment-score">+${comment.user_score}</span>
<time class="comment-time">${formatRelativeTime(comment.created_time)}</time>
</div>
${isOwner ? `
<div class="comment-actions">
<button class="action-btn edit-btn" aria-label="Edit">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</button>
<button class="action-btn delete-btn" aria-label="Delete">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
</button>
</div>
` : ''}
</header>
<div class="comment-body">
<rant-content text="${this.escapeAttr(comment.body)}"></rant-content>
</div>
<footer class="comment-footer">
<vote-buttons
score="${comment.score}"
vote-state="${comment.vote_state}"
type="comment"
item-id="${comment.id}">
</vote-buttons>
</footer>
</article>
`);
}
escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'vote', this.handleVote);
}
handleClick(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
const cancelBtn = e.target.closest('.cancel-edit-btn');
const saveBtn = e.target.closest('.save-edit-btn');
const username = e.target.closest('.comment-username');
const avatar = e.target.closest('user-avatar');
if (editBtn) {
this.startEditing();
return;
}
if (deleteBtn) {
this.confirmDelete();
return;
}
if (cancelBtn) {
this.cancelEditing();
return;
}
if (saveBtn) {
this.saveEdit();
return;
}
if (username || avatar) {
this.getRouter()?.goToUser(this.commentData.user_username);
}
}
async handleVote(e) {
const { vote, itemId } = e.detail;
const voteButtons = this.$('vote-buttons');
if (voteButtons) {
voteButtons.disable();
}
try {
const result = await this.getApi()?.voteComment(itemId, vote);
if (result?.success && result.comment) {
this.commentData.score = result.comment.score;
this.commentData.vote_state = result.comment.vote_state;
if (voteButtons) {
voteButtons.updateVote(result.comment.score, result.comment.vote_state);
voteButtons.enable();
}
}
} catch (error) {
if (voteButtons) {
voteButtons.enable();
}
}
}
startEditing() {
this.isEditing = true;
this.render();
const textarea = this.$('.comment-edit-input');
if (textarea) {
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}
}
cancelEditing() {
this.isEditing = false;
this.render();
}
async saveEdit() {
const textarea = this.$('.comment-edit-input');
if (!textarea) return;
const newBody = textarea.value.trim();
if (!newBody || newBody === this.commentData.body) {
this.cancelEditing();
return;
}
try {
const result = await this.getApi()?.updateComment(this.commentData.id, newBody);
if (result?.success) {
this.commentData.body = newBody;
this.isEditing = false;
this.render();
this.emit('comment-updated', { comment: this.commentData });
}
} catch (error) {
this.getApp()?.toast?.error('Failed to update comment');
}
}
confirmDelete() {
if (confirm('Delete this comment?')) {
this.deleteComment();
}
}
async deleteComment() {
try {
const result = await this.getApi()?.deleteComment(this.commentData.id);
if (result?.success) {
this.emit('comment-deleted', { commentId: this.commentData.id });
this.remove();
}
} catch (error) {
this.getApp()?.toast?.error('Failed to delete comment');
}
}
getCommentId() {
return this.commentData?.id || this.getAttr('comment-id');
}
}
customElements.define('comment-item', CommentItem);
export { CommentItem };
+146
View File
@@ -0,0 +1,146 @@
/**
* @fileoverview Image Preview Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Displays images with lightbox functionality
* @keywords image, preview, lightbox, gallery, media
*/
import { BaseComponent } from './base-component.js';
import { buildDevrantImageUrl, isGifUrl } from '../utils/url.js';
class ImagePreview extends BaseComponent {
static get observedAttributes() {
return ['src', 'width', 'height', 'alt'];
}
init() {
this.render();
this.bindEvents();
}
render() {
const src = this.getAttr('src');
const width = this.getAttr('width');
const height = this.getAttr('height');
const alt = this.getAttr('alt') || 'Image';
if (!src) {
this.setHtml('');
return;
}
const imageUrl = buildDevrantImageUrl(src);
const isGif = isGifUrl(imageUrl);
const aspectRatio = width && height ? width / height : null;
this.addClass('image-preview');
this.setHtml(`
<div class="image-container" ${aspectRatio ? `style="aspect-ratio: ${aspectRatio}"` : ''}>
<img class="preview-image"
src="${imageUrl}"
alt="${alt}"
loading="lazy"
${width ? `width="${width}"` : ''}
${height ? `height="${height}"` : ''}>
${isGif ? '<span class="gif-badge">GIF</span>' : ''}
<button class="expand-btn" aria-label="View full size">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z"/>
</svg>
</button>
</div>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
}
handleClick(e) {
const expandBtn = e.target.closest('.expand-btn');
const img = e.target.closest('.preview-image');
if (expandBtn || img) {
e.preventDefault();
this.openLightbox();
}
}
openLightbox() {
const src = this.getAttr('src');
if (!src) return;
const imageUrl = buildDevrantImageUrl(src);
const lightbox = document.createElement('image-lightbox');
lightbox.setAttribute('src', imageUrl);
document.body.appendChild(lightbox);
}
onAttributeChanged(name, oldValue, newValue) {
this.render();
}
}
customElements.define('image-preview', ImagePreview);
class ImageLightbox extends BaseComponent {
static get observedAttributes() {
return ['src'];
}
init() {
this.keydownHandler = (e) => this.handleKeydown(e);
this.render();
this.bindEvents();
}
render() {
const src = this.getAttr('src');
this.addClass('lightbox');
this.setHtml(`
<div class="lightbox-backdrop"></div>
<div class="lightbox-content">
<button class="lightbox-close" aria-label="Close">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
<img class="lightbox-image" src="${src}" alt="Full size image">
</div>
`);
requestAnimationFrame(() => this.addClass('lightbox-visible'));
}
bindEvents() {
this.on(this, 'click', this.handleClick);
document.addEventListener('keydown', this.keydownHandler);
}
handleClick(e) {
if (e.target.classList.contains('lightbox-backdrop') ||
e.target.closest('.lightbox-close')) {
this.close();
}
}
handleKeydown(e) {
if (e.key === 'Escape') {
this.close();
}
}
close() {
document.removeEventListener('keydown', this.keydownHandler);
this.removeClass('lightbox-visible');
setTimeout(() => this.remove(), 300);
}
}
customElements.define('image-lightbox', ImageLightbox);
export { ImagePreview, ImageLightbox };
+72
View File
@@ -0,0 +1,72 @@
/**
* @fileoverview Link Preview Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Displays link previews with domain info
* @keywords link, preview, url, external, domain
*/
import { BaseComponent } from './base-component.js';
import { getDomain, sanitizeUrl } from '../utils/url.js';
class LinkPreview extends BaseComponent {
static get observedAttributes() {
return ['url', 'title'];
}
init() {
this.render();
}
render() {
const url = this.getAttr('url');
const title = this.getAttr('title');
if (!url) {
this.setHtml('');
return;
}
const safeUrl = sanitizeUrl(url);
if (!safeUrl) {
this.setHtml('');
return;
}
const domain = getDomain(safeUrl);
const displayTitle = title || safeUrl;
this.addClass('link-preview');
this.setHtml(`
<a href="${safeUrl}" class="link-card" target="_blank" rel="noopener noreferrer">
<div class="link-icon">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>
</div>
<div class="link-info">
<span class="link-title">${this.truncate(displayTitle, 60)}</span>
<span class="link-domain">${domain}</span>
</div>
<div class="link-external">
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/>
</svg>
</div>
</a>
`);
}
truncate(text, length) {
if (text.length <= length) return text;
return text.substring(0, length) + '...';
}
onAttributeChanged(name, oldValue, newValue) {
this.render();
}
}
customElements.define('link-preview', LinkPreview);
export { LinkPreview };
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview Loading Spinner Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Animated loading indicator for async operations
* @keywords loading, spinner, animation, progress, indicator
*/
import { BaseComponent } from './base-component.js';
class LoadingSpinner extends BaseComponent {
static get observedAttributes() {
return ['size', 'text'];
}
init() {
this.render();
}
render() {
const size = this.getAttr('size') || 'medium';
const text = this.getAttr('text') || '';
this.addClass('spinner', `spinner-${size}`);
this.setHtml(`
<div class="spinner-circle">
<div class="spinner-inner"></div>
</div>
${text ? `<span class="spinner-text">${text}</span>` : ''}
`);
}
onAttributeChanged(name, oldValue, newValue) {
this.render();
}
setText(text) {
this.setAttr('text', text);
}
setSize(size) {
this.setAttr('size', size);
}
}
customElements.define('loading-spinner', LoadingSpinner);
export { LoadingSpinner };
+133
View File
@@ -0,0 +1,133 @@
/**
* @fileoverview Login Form Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description User authentication form with validation
* @keywords login, form, authentication, credentials, signin
*/
import { BaseComponent } from './base-component.js';
class LoginForm extends BaseComponent {
init() {
this.isLoading = false;
this.render();
this.bindEvents();
}
render() {
this.setHtml(`
<form class="login-form">
<div class="form-header">
<h2 class="form-title">Sign In</h2>
<p class="form-subtitle">Enter your DevRant credentials</p>
</div>
<div class="form-group">
<label for="login-username" class="form-label">Username</label>
<input type="text"
id="login-username"
class="form-input"
name="username"
autocomplete="username"
required
${this.isLoading ? 'disabled' : ''}>
</div>
<div class="form-group">
<label for="login-password" class="form-label">Password</label>
<input type="password"
id="login-password"
class="form-input"
name="password"
autocomplete="current-password"
required
${this.isLoading ? 'disabled' : ''}>
</div>
<div class="form-group form-checkbox">
<input type="checkbox"
id="login-remember"
name="remember"
checked
${this.isLoading ? 'disabled' : ''}>
<label for="login-remember">Remember me</label>
</div>
<div class="form-error" hidden></div>
<button type="submit"
class="form-submit"
${this.isLoading ? 'disabled' : ''}>
${this.isLoading ? '<loading-spinner size="small"></loading-spinner>' : 'Sign In'}
</button>
</form>
`);
}
bindEvents() {
const form = this.$('form');
if (form) {
this.on(form, 'submit', this.handleSubmit);
}
}
async handleSubmit(e) {
e.preventDefault();
if (this.isLoading) return;
const username = this.$('#login-username').value.trim();
const password = this.$('#login-password').value;
const remember = this.$('#login-remember').checked;
if (!username || !password) {
this.showError('Please enter username and password');
return;
}
this.setLoading(true);
this.hideError();
try {
const result = await this.getAuth()?.login(username, password, remember);
if (result?.success) {
this.emit('login-success', { user: result.user });
this.getRouter()?.goHome();
} else {
this.showError(result?.error || 'Login failed');
}
} catch (error) {
this.showError('Connection error. Please try again.');
} finally {
this.setLoading(false);
}
}
setLoading(loading) {
this.isLoading = loading;
this.render();
this.bindEvents();
}
showError(message) {
const errorEl = this.$('.form-error');
if (errorEl) {
errorEl.textContent = message;
errorEl.hidden = false;
}
}
hideError() {
const errorEl = this.$('.form-error');
if (errorEl) {
errorEl.hidden = true;
}
}
reset() {
const form = this.$('form');
if (form) {
form.reset();
}
this.hideError();
}
}
customElements.define('login-form', LoginForm);
export { LoginForm };
+232
View File
@@ -0,0 +1,232 @@
/**
* @fileoverview Notification List Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Displays user notifications and mentions
* @keywords notification, list, alerts, mentions, updates
*/
import { BaseComponent } from './base-component.js';
import { formatRelativeTime } from '../utils/date.js';
class NotificationList extends BaseComponent {
init() {
this.notifications = [];
this.isLoading = false;
this.unreadCount = 0;
this.render();
this.bindEvents();
}
async load() {
if (!this.isLoggedIn()) {
this.render();
return;
}
this.isLoading = true;
this.render();
try {
const result = await this.getApi()?.getNotifications();
if (result?.success) {
this.notifications = result.notifications || [];
this.unreadCount = result.unread?.total || 0;
}
} catch (error) {
this.notifications = [];
} finally {
this.isLoading = false;
this.render();
}
}
render() {
this.addClass('notification-list');
if (!this.isLoggedIn()) {
this.setHtml(`
<div class="notification-auth">
<p>Sign in to view notifications</p>
<button class="btn btn-primary login-btn">Sign In</button>
</div>
`);
return;
}
if (this.isLoading) {
this.setHtml(`
<div class="notification-loading">
<loading-spinner text="Loading notifications..."></loading-spinner>
</div>
`);
return;
}
if (this.notifications.length === 0) {
this.setHtml(`
<div class="notification-empty">
<svg viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/>
</svg>
<p>No notifications</p>
</div>
`);
return;
}
this.setHtml(`
<header class="notification-header">
<h2>Notifications</h2>
${this.unreadCount > 0 ? `
<button class="clear-btn">Mark all read</button>
` : ''}
</header>
<div class="notification-items">
${this.notifications.map(notif => `
<notification-item
data-notif='${JSON.stringify(notif).replace(/'/g, '&#39;')}'>
</notification-item>
`).join('')}
</div>
`);
this.initNotificationItems();
}
initNotificationItems() {
const items = this.$$('notification-item');
items.forEach(item => {
const notifData = item.dataset.notif;
if (notifData) {
try {
const notif = JSON.parse(notifData.replace(/&#39;/g, "'"));
item.setNotification(notif);
} catch (e) {}
}
});
}
bindEvents() {
this.on(this, 'click', this.handleClick);
}
handleClick(e) {
const loginBtn = e.target.closest('.login-btn');
const clearBtn = e.target.closest('.clear-btn');
if (loginBtn) {
this.getRouter()?.goToLogin();
return;
}
if (clearBtn) {
this.clearNotifications();
}
}
async clearNotifications() {
try {
await this.getApi()?.clearNotifications();
this.unreadCount = 0;
this.emit('notifications-cleared');
await this.load();
} catch (error) {}
}
onConnected() {
this.load();
}
onDisconnected() {
this.isLoading = false;
}
getUnreadCount() {
return this.unreadCount;
}
refresh() {
this.load();
}
}
customElements.define('notification-list', NotificationList);
class NotificationItem extends BaseComponent {
init() {
this.notifData = null;
this.render();
this.bindEvents();
}
setNotification(notif) {
this.notifData = notif;
this.render();
}
render() {
if (!this.notifData) {
this.setHtml('');
return;
}
const notif = this.notifData;
const isUnread = notif.read === 0;
const typeLabel = this.getTypeLabel(notif.type);
const username = notif.username || notif.user_username || notif.name || 'Someone';
this.addClass('notification-item');
if (isUnread) {
this.addClass('unread');
}
this.setHtml(`
<div class="notif-content">
<div class="notif-icon">${this.getTypeIcon(notif.type)}</div>
<div class="notif-body">
<span class="notif-username">${username}</span>
<span class="notif-action">${typeLabel}</span>
</div>
<time class="notif-time">${formatRelativeTime(notif.created_time)}</time>
</div>
`);
}
getTypeLabel(type) {
const labels = {
'comment_mention': 'mentioned you',
'comment_content': 'commented on your rant',
'comment_vote': 'upvoted your comment',
'rant_vote': 'upvoted your rant',
'comment_discuss': 'replied to a discussion'
};
return labels[type] || 'interacted';
}
getTypeIcon(type) {
if (type.includes('mention')) {
return `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10h5v-2h-5c-4.34 0-8-3.66-8-8s3.66-8 8-8 8 3.66 8 8v1.43c0 .79-.71 1.57-1.5 1.57s-1.5-.78-1.5-1.57V12c0-2.76-2.24-5-5-5s-5 2.24-5 5 2.24 5 5 5c1.38 0 2.64-.56 3.54-1.47.65.89 1.77 1.47 2.96 1.47 1.97 0 3.5-1.6 3.5-3.57V12c0-5.52-4.48-10-10-10zm0 13c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/></svg>`;
}
if (type.includes('vote')) {
return `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>`;
}
return `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"/></svg>`;
}
bindEvents() {
this.on(this, 'click', this.handleClick);
}
handleClick() {
if (this.notifData?.rant_id) {
this.getRouter()?.goToRant(
this.notifData.rant_id,
this.notifData.comment_id
);
}
}
}
customElements.define('notification-item', NotificationItem);
export { NotificationList, NotificationItem };
+249
View File
@@ -0,0 +1,249 @@
/**
* @fileoverview Post Form Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Form for creating new rants
* @keywords post, form, create, rant, new
*/
import { BaseComponent } from './base-component.js';
class PostForm extends BaseComponent {
init() {
this.isSubmitting = false;
this.render();
this.bindEvents();
this.loadDraft();
}
render() {
const isLoggedIn = this.isLoggedIn();
this.addClass('post-form');
if (!isLoggedIn) {
this.setHtml(`
<div class="post-form-auth">
<p>Sign in to post</p>
<button class="btn btn-primary login-btn">Sign In</button>
</div>
`);
return;
}
this.setHtml(`
<form class="post-form-inner">
<header class="form-header">
<h2>Create Rant</h2>
<button type="button" class="close-btn" aria-label="Close">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</header>
<div class="form-group">
<textarea
class="post-input"
placeholder="What's on your mind?"
rows="6"
maxlength="5000"
${this.isSubmitting ? 'disabled' : ''}></textarea>
</div>
<div class="form-group">
<input
type="text"
class="tags-input"
placeholder="Tags (comma separated)"
maxlength="100"
${this.isSubmitting ? 'disabled' : ''}>
</div>
<div class="form-actions">
<span class="char-count">0 / 5000</span>
<button type="submit"
class="btn btn-primary submit-btn"
${this.isSubmitting ? 'disabled' : ''}>
${this.isSubmitting ? '<loading-spinner size="small"></loading-spinner>' : 'Post'}
</button>
</div>
</form>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'submit', this.handleSubmit);
this.on(this, 'input', this.handleInput);
}
handleClick(e) {
const loginBtn = e.target.closest('.login-btn');
const closeBtn = e.target.closest('.close-btn');
if (loginBtn) {
this.getRouter()?.goToLogin();
return;
}
if (closeBtn) {
this.emit('close');
}
}
async handleSubmit(e) {
e.preventDefault();
if (this.isSubmitting) return;
const textarea = this.$('.post-input');
const tagsInput = this.$('.tags-input');
if (!textarea) return;
const text = textarea.value.trim();
if (!text) {
this.getApp()?.toast?.error('Please enter some text');
return;
}
const tags = tagsInput ? tagsInput.value.trim() : '';
this.isSubmitting = true;
this.render();
try {
const result = await this.getApi()?.postRant(text, tags);
if (result?.success) {
this.clearDraft();
this.emit('post-created', { rantId: result.rantId });
this.getApp()?.toast?.success('Rant posted successfully');
this.isSubmitting = false;
this.render();
this.getRouter()?.goToRant(result.rantId);
} else {
this.getApp()?.toast?.error(result?.error || 'Failed to post');
this.isSubmitting = false;
this.render();
}
} catch (error) {
this.getApp()?.toast?.error('Failed to post');
this.isSubmitting = false;
this.render();
}
}
handleInput(e) {
if (e.target.classList.contains('post-input')) {
const textarea = e.target;
const count = textarea.value.length;
const countEl = this.$('.char-count');
if (countEl) {
countEl.textContent = `${count} / 5000`;
}
this.saveDraft(textarea.value);
}
}
loadDraft() {
const draft = this.getStorage()?.getDraftRant();
if (draft) {
const textarea = this.$('.post-input');
if (textarea) {
textarea.value = draft;
const countEl = this.$('.char-count');
if (countEl) {
countEl.textContent = `${draft.length} / 5000`;
}
}
}
}
saveDraft(text) {
this.getStorage()?.setDraftRant(text);
}
clearDraft() {
this.getStorage()?.clearDraftRant();
}
reset() {
const textarea = this.$('.post-input');
if (textarea) {
textarea.value = '';
}
const tagsInput = this.$('.tags-input');
if (tagsInput) {
tagsInput.value = '';
}
const countEl = this.$('.char-count');
if (countEl) {
countEl.textContent = '0 / 5000';
}
this.clearDraft();
}
focus() {
const textarea = this.$('.post-input');
if (textarea) {
textarea.focus();
}
}
}
customElements.define('post-form', PostForm);
class PostModal extends BaseComponent {
init() {
this.keydownHandler = (e) => this.handleKeydown(e);
this.render();
this.bindEvents();
}
render() {
this.addClass('modal', 'post-modal');
this.setHtml(`
<div class="modal-backdrop"></div>
<div class="modal-content">
<post-form></post-form>
</div>
`);
requestAnimationFrame(() => this.addClass('modal-visible'));
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'close', this.close);
this.on(this, 'post-created', this.close);
document.addEventListener('keydown', this.keydownHandler);
}
handleClick(e) {
if (e.target.classList.contains('modal-backdrop')) {
this.close();
}
}
handleKeydown(e) {
if (e.key === 'Escape') {
this.close();
}
}
close() {
document.removeEventListener('keydown', this.keydownHandler);
this.removeClass('modal-visible');
setTimeout(() => this.remove(), 300);
}
open() {
document.body.appendChild(this);
const form = this.$('post-form');
if (form) {
form.focus();
}
}
}
customElements.define('post-modal', PostModal);
export { PostForm, PostModal };
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview Rant Card Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Compact rant display for feed listings
* @keywords rant, card, feed, listing, preview
*/
import { BaseComponent } from './base-component.js';
import { formatRelativeTime } from '../utils/date.js';
import { buildDevrantImageUrl } from '../utils/url.js';
class RantCard extends BaseComponent {
static get observedAttributes() {
return ['rant-id'];
}
init() {
this.rantData = null;
this.render();
this.bindEvents();
}
setRant(rant) {
this.rantData = rant;
this.setAttr('rant-id', rant.id);
this.render();
}
render() {
if (!this.rantData) {
this.setHtml('<div class="rant-card-skeleton"></div>');
return;
}
const rant = this.rantData;
const hasImage = rant.attached_image && typeof rant.attached_image === 'object';
const imageUrl = hasImage ? buildDevrantImageUrl(rant.attached_image.url) : null;
this.addClass('rant-card');
this.setHtml(`
<article class="card-content">
<header class="card-header">
<user-avatar
avatar='${JSON.stringify(rant.user_avatar)}'
username="${rant.user_username}"
size="small">
</user-avatar>
<div class="card-meta">
<span class="card-username">${rant.user_username}</span>
<span class="card-score">+${rant.user_score}</span>
<span class="card-time">${formatRelativeTime(rant.created_time)}</span>
</div>
</header>
<div class="card-body">
<rant-content text="${this.escapeAttr(rant.text)}"></rant-content>
${imageUrl ? `
<div class="card-image">
<image-preview
src="${imageUrl}"
width="${rant.attached_image.width}"
height="${rant.attached_image.height}">
</image-preview>
</div>
` : ''}
</div>
<footer class="card-footer">
<vote-buttons
score="${rant.score}"
vote-state="${rant.vote_state}"
type="rant"
item-id="${rant.id}">
</vote-buttons>
<button class="card-comments" aria-label="${rant.num_comments} comments">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"/>
</svg>
<span>${rant.num_comments}</span>
</button>
${rant.tags && rant.tags.length > 0 ? `
<div class="card-tags">
${rant.tags.slice(0, 3).map(tag => `
<span class="tag">${tag}</span>
`).join('')}
</div>
` : ''}
</footer>
</article>
`);
}
escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'vote', this.handleVote);
}
handleClick(e) {
const username = e.target.closest('.card-username');
const avatar = e.target.closest('user-avatar');
const commentsBtn = e.target.closest('.card-comments');
const voteBtn = e.target.closest('.vote-btn');
const tag = e.target.closest('.tag');
const imagePreview = e.target.closest('image-preview');
const youtubeEmbed = e.target.closest('youtube-embed');
const linkPreview = e.target.closest('link-preview');
if (voteBtn || imagePreview || youtubeEmbed || linkPreview) {
return;
}
if (username || avatar) {
e.stopPropagation();
this.getRouter()?.goToUser(this.rantData.user_username);
return;
}
if (tag) {
e.stopPropagation();
this.getRouter()?.goToSearch(tag.textContent);
return;
}
this.getRouter()?.goToRant(this.rantData.id);
}
async handleVote(e) {
e.stopPropagation();
const { vote, itemId } = e.detail;
const voteButtons = this.$('vote-buttons');
if (voteButtons) {
voteButtons.disable();
}
try {
const result = await this.getApi()?.voteRant(itemId, vote);
if (result?.success && result.rant) {
this.rantData.score = result.rant.score;
this.rantData.vote_state = result.rant.vote_state;
if (voteButtons) {
voteButtons.updateVote(result.rant.score, result.rant.vote_state);
voteButtons.enable();
}
}
} catch (error) {
if (voteButtons) {
voteButtons.enable();
}
}
}
getRantId() {
return this.rantData?.id || this.getAttr('rant-id');
}
}
customElements.define('rant-card', RantCard);
export { RantCard };
+78
View File
@@ -0,0 +1,78 @@
/**
* @fileoverview Rant Content Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Renders rant text with markdown, images, and media
* @keywords rant, content, markdown, media, render
*/
import { BaseComponent } from './base-component.js';
import { markdownRenderer } from '../utils/markdown.js';
import { extractImageUrls, extractYoutubeUrls, extractNonMediaUrls } from '../utils/url.js';
class RantContent extends BaseComponent {
static get observedAttributes() {
return ['text'];
}
init() {
this.render();
}
render() {
const text = this.getAttr('text') || '';
this.addClass('rant-content');
const images = extractImageUrls(text);
const youtubeLinks = extractYoutubeUrls(text);
const otherLinks = extractNonMediaUrls(text);
const renderedText = markdownRenderer.render(text);
let html = `<div class="content-text">${renderedText}</div>`;
if (images.length > 0) {
html += `
<div class="content-images">
${images.map(url => `
<image-preview src="${url}"></image-preview>
`).join('')}
</div>
`;
}
if (youtubeLinks.length > 0) {
html += `
<div class="content-videos">
${youtubeLinks.map(url => `
<youtube-embed url="${url}"></youtube-embed>
`).join('')}
</div>
`;
}
if (otherLinks.length > 0) {
html += `
<div class="content-links">
${otherLinks.slice(0, 3).map(url => `
<link-preview url="${url}"></link-preview>
`).join('')}
</div>
`;
}
this.setHtml(html);
}
onAttributeChanged(name, oldValue, newValue) {
this.render();
}
setText(text) {
this.setAttr('text', text);
}
}
customElements.define('rant-content', RantContent);
export { RantContent };
+243
View File
@@ -0,0 +1,243 @@
/**
* @fileoverview Rant Detail Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Full rant view with comments
* @keywords rant, detail, view, full, comments
*/
import { BaseComponent } from './base-component.js';
import { formatRelativeTime, formatFullDate } from '../utils/date.js';
import { buildDevrantImageUrl } from '../utils/url.js';
class RantDetail extends BaseComponent {
static get observedAttributes() {
return ['rant-id'];
}
init() {
this.rantData = null;
this.comments = [];
this.isLoading = false;
this.render();
this.bindEvents();
}
async load(rantId) {
this.setAttr('rant-id', rantId);
this.isLoading = true;
this.render();
try {
const result = await this.getApi()?.getRant(rantId);
if (result?.success) {
this.rantData = result.rant;
this.comments = result.comments || [];
}
} catch (error) {
this.rantData = null;
this.comments = [];
} finally {
this.isLoading = false;
this.render();
}
}
setRant(rant, comments = []) {
this.rantData = rant;
this.comments = comments;
this.setAttr('rant-id', rant.id);
this.render();
}
render() {
if (this.isLoading) {
this.setHtml(`
<div class="rant-detail-loading">
<loading-spinner text="Loading rant..."></loading-spinner>
</div>
`);
return;
}
if (!this.rantData) {
this.setHtml(`
<div class="rant-detail-error">
<p>Rant not found</p>
<button class="btn btn-primary back-btn">Go Back</button>
</div>
`);
return;
}
const rant = this.rantData;
const hasImage = rant.attached_image && typeof rant.attached_image === 'object';
const imageUrl = hasImage ? buildDevrantImageUrl(rant.attached_image.url) : null;
this.addClass('rant-detail');
this.setHtml(`
<article class="detail-content">
<header class="detail-header">
<button class="back-btn" aria-label="Go back">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button>
<h1 class="detail-title">Rant</h1>
</header>
<div class="detail-author">
<user-avatar
avatar='${JSON.stringify(rant.user_avatar)}'
username="${rant.user_username}"
size="medium">
</user-avatar>
<div class="author-info">
<span class="author-username">${rant.user_username}</span>
<span class="author-score">+${rant.user_score}</span>
</div>
<time class="detail-time" datetime="${new Date(rant.created_time * 1000).toISOString()}">
${formatFullDate(rant.created_time)}
</time>
</div>
<div class="detail-body">
<rant-content text="${this.escapeAttr(rant.text)}"></rant-content>
${imageUrl ? `
<div class="detail-image">
<image-preview
src="${imageUrl}"
width="${rant.attached_image.width}"
height="${rant.attached_image.height}">
</image-preview>
</div>
` : ''}
</div>
${rant.tags && rant.tags.length > 0 ? `
<div class="detail-tags">
${rant.tags.map(tag => `
<button class="tag" data-tag="${tag}">${tag}</button>
`).join('')}
</div>
` : ''}
<footer class="detail-footer">
<vote-buttons
score="${rant.score}"
vote-state="${rant.vote_state}"
type="rant"
item-id="${rant.id}">
</vote-buttons>
<span class="detail-comments-count">${this.comments.length} comments</span>
</footer>
</article>
<section class="comments-section">
<comment-form rant-id="${rant.id}"></comment-form>
<div class="comments-list">
${this.comments.map(comment => `
<comment-item
comment-id="${comment.id}"
data-comment='${JSON.stringify(comment).replace(/'/g, '&#39;')}'>
</comment-item>
`).join('')}
</div>
</section>
`);
this.initComments();
}
escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
initComments() {
const commentItems = this.$$('comment-item');
commentItems.forEach(item => {
const commentData = item.dataset.comment;
if (commentData) {
try {
const comment = JSON.parse(commentData.replace(/&#39;/g, "'"));
item.setComment(comment);
} catch (e) {}
}
});
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'vote', this.handleVote);
this.on(this, 'comment-posted', this.handleCommentPosted);
}
handleClick(e) {
const backBtn = e.target.closest('.back-btn');
const username = e.target.closest('.author-username');
const avatar = e.target.closest('user-avatar');
const tag = e.target.closest('.tag');
if (backBtn) {
e.preventDefault();
window.history.back();
return;
}
if (username || avatar) {
this.getRouter()?.goToUser(this.rantData.user_username);
return;
}
if (tag) {
const tagText = tag.dataset.tag || tag.textContent;
this.getRouter()?.goToSearch(tagText);
}
}
async handleVote(e) {
const { vote, itemId, type } = e.detail;
if (type === 'rant') {
const result = await this.getApi()?.voteRant(itemId, vote);
if (result?.success && result.rant) {
this.rantData.score = result.rant.score;
this.rantData.vote_state = result.rant.vote_state;
const voteButtons = this.$('vote-buttons[type="rant"]');
if (voteButtons) {
voteButtons.updateVote(result.rant.score, result.rant.vote_state);
}
}
}
}
async handleCommentPosted(e) {
await this.load(this.rantData.id);
this.scrollToComments();
}
scrollToComment(commentId) {
const commentEl = this.$(`comment-item[comment-id="${commentId}"]`);
if (commentEl) {
commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
commentEl.classList.add('highlight');
setTimeout(() => commentEl.classList.remove('highlight'), 2000);
}
}
scrollToComments() {
const commentsSection = this.$('.comments-section');
if (commentsSection) {
commentsSection.scrollIntoView({ behavior: 'smooth' });
}
}
getRantId() {
return this.rantData?.id || this.getAttr('rant-id');
}
}
customElements.define('rant-detail', RantDetail);
export { RantDetail };
+242
View File
@@ -0,0 +1,242 @@
/**
* @fileoverview Rant Feed Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Infinite scrolling list of rants
* @keywords feed, list, rants, infinite, scroll
*/
import { BaseComponent } from './base-component.js';
class RantFeed extends BaseComponent {
static get observedAttributes() {
return ['sort', 'feed-type'];
}
init() {
this.rants = [];
this.skip = 0;
this.limit = 20;
this.isLoading = false;
this.hasMore = true;
this.sort = this.getAttr('sort') || 'recent';
this.feedType = this.getAttr('feed-type') || 'rants';
this.render();
this.bindEvents();
}
async load(reset = false) {
if (this.isLoading) return;
if (!reset && !this.hasMore) return;
if (reset) {
this.rants = [];
this.skip = 0;
this.hasMore = true;
}
this.isLoading = true;
this.updateLoadingState();
try {
let result;
const api = this.getApi();
switch (this.feedType) {
case 'weekly':
result = await api?.getWeeklyRants(this.sort, this.limit, this.skip);
break;
case 'collabs':
result = await api?.getCollabs(this.sort, this.limit, this.skip);
break;
case 'stories':
result = await api?.getStories(this.sort, this.limit, this.skip);
break;
case 'search':
break;
default:
result = await api?.getRants(this.sort, this.limit, this.skip);
}
if (result?.success) {
const newRants = result.rants || [];
this.rants = [...this.rants, ...newRants];
this.skip += newRants.length;
this.hasMore = newRants.length >= this.limit;
} else {
this.hasMore = false;
}
} catch (error) {
this.hasMore = false;
} finally {
this.isLoading = false;
this.render();
}
}
async search(term) {
if (this.isLoading) return;
this.isLoading = true;
this.rants = [];
this.updateLoadingState();
try {
const result = await this.getApi()?.search(term);
if (result?.success) {
this.rants = result.rants || [];
}
this.hasMore = false;
} catch (error) {
this.rants = [];
} finally {
this.isLoading = false;
this.render();
}
}
render() {
this.addClass('rant-feed');
if (this.rants.length === 0 && !this.isLoading) {
this.setHtml(`
<div class="feed-empty">
<p>No rants found</p>
</div>
`);
return;
}
this.setHtml(`
<div class="feed-controls">
<div class="sort-tabs">
<button class="sort-tab ${this.sort === 'recent' ? 'active' : ''}" data-sort="recent">Recent</button>
<button class="sort-tab ${this.sort === 'top' ? 'active' : ''}" data-sort="top">Top</button>
<button class="sort-tab ${this.sort === 'algo' ? 'active' : ''}" data-sort="algo">Algo</button>
</div>
</div>
<div class="feed-list">
${this.rants.map(rant => `
<rant-card rant-id="${rant.id}"></rant-card>
`).join('')}
</div>
${this.isLoading ? `
<div class="feed-loading">
<loading-spinner></loading-spinner>
</div>
` : ''}
${this.hasMore && !this.isLoading ? `
<div class="feed-loadmore">
<button class="btn btn-secondary load-more-btn">Load More</button>
</div>
` : ''}
`);
this.initRantCards();
}
initRantCards() {
const cards = this.$$('rant-card');
cards.forEach((card, index) => {
if (this.rants[index]) {
card.setRant(this.rants[index]);
}
});
}
updateLoadingState() {
const loadingEl = this.$('.feed-loading');
if (this.isLoading && !loadingEl && this.rants.length > 0) {
const loadMore = this.$('.feed-loadmore');
if (loadMore) {
loadMore.innerHTML = '<loading-spinner></loading-spinner>';
}
}
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.setupInfiniteScroll();
}
handleClick(e) {
const sortTab = e.target.closest('.sort-tab');
const loadMoreBtn = e.target.closest('.load-more-btn');
if (sortTab) {
const newSort = sortTab.dataset.sort;
if (newSort !== this.sort) {
this.sort = newSort;
this.setAttr('sort', newSort);
this.load(true);
}
return;
}
if (loadMoreBtn) {
this.load();
}
}
setupInfiniteScroll() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.isLoading && this.hasMore) {
this.load();
}
});
},
{ rootMargin: '200px' }
);
this.intersectionObserver = observer;
}
onConnected() {
this.load(true);
}
onDisconnected() {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
this.isLoading = false;
this.hasMore = true;
}
onAttributeChanged(name, oldValue, newValue) {
if (name === 'sort' && oldValue !== newValue) {
this.sort = newValue;
this.load(true);
}
if (name === 'feed-type' && oldValue !== newValue) {
this.feedType = newValue;
this.load(true);
}
}
setSort(sort) {
this.sort = sort;
this.setAttr('sort', sort);
this.load(true);
}
setFeedType(type) {
this.feedType = type;
this.setAttr('feed-type', type);
this.load(true);
}
refresh() {
this.load(true);
}
getRants() {
return this.rants;
}
}
customElements.define('rant-feed', RantFeed);
export { RantFeed };
+62
View File
@@ -0,0 +1,62 @@
/**
* @fileoverview Theme Selector Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description UI for selecting application color themes
* @keywords theme, selector, dark, light, appearance
*/
import { BaseComponent } from './base-component.js';
import { THEMES } from '../services/theme.js';
class ThemeSelector extends BaseComponent {
init() {
this.themeChangeHandler = () => this.render();
this.render();
this.bindEvents();
}
render() {
const currentTheme = this.getTheme()?.getTheme() || 'dark';
const themes = Object.entries(THEMES);
this.setHtml(`
<div class="theme-selector">
<label class="theme-label">Theme</label>
<div class="theme-options">
${themes.map(([key, theme]) => `
<button class="theme-option ${key === currentTheme ? 'active' : ''}"
data-theme="${key}"
aria-pressed="${key === currentTheme}">
<span class="theme-preview theme-preview-${key}"></span>
<span class="theme-name">${theme.name}</span>
</button>
`).join('')}
</div>
</div>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
window.addEventListener('rantii:theme-change', this.themeChangeHandler);
}
onDisconnected() {
window.removeEventListener('rantii:theme-change', this.themeChangeHandler);
}
handleClick(e) {
const option = e.target.closest('.theme-option');
if (!option) return;
const theme = option.dataset.theme;
if (theme) {
this.getTheme()?.setTheme(theme);
this.render();
}
}
}
customElements.define('theme-selector', ThemeSelector);
export { ThemeSelector };
+124
View File
@@ -0,0 +1,124 @@
/**
* @fileoverview Toast Notification Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Non-intrusive notification messages for user feedback
* @keywords toast, notification, alert, message, feedback
*/
import { BaseComponent } from './base-component.js';
class ToastNotification extends BaseComponent {
static get observedAttributes() {
return ['type', 'message', 'duration'];
}
init() {
this.autoHideTimer = null;
this.render();
}
render() {
const type = this.getAttr('type') || 'info';
const message = this.getAttr('message') || '';
this.addClass('toast', `toast-${type}`);
this.setHtml(`
<div class="toast-content">
<span class="toast-icon">${this.getIcon(type)}</span>
<span class="toast-message">${message}</span>
<button class="toast-close" aria-label="Close">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
`);
this.bindEvents();
this.startAutoHide();
}
getIcon(type) {
const icons = {
success: `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
error: `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>`,
warning: `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>`,
info: `<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>`
};
return icons[type] || icons.info;
}
bindEvents() {
const closeBtn = this.$('.toast-close');
if (closeBtn) {
this.on(closeBtn, 'click', () => this.dismiss());
}
}
startAutoHide() {
const duration = parseInt(this.getAttr('duration') || '4000', 10);
if (duration > 0) {
this.autoHideTimer = setTimeout(() => this.dismiss(), duration);
}
}
dismiss() {
if (this.autoHideTimer) {
clearTimeout(this.autoHideTimer);
}
this.addClass('toast-hiding');
setTimeout(() => {
this.emit('toast-dismissed');
this.remove();
}, 300);
}
show(message, type = 'info', duration = 4000) {
this.setAttr('message', message);
this.setAttr('type', type);
this.setAttr('duration', duration.toString());
this.render();
}
}
customElements.define('toast-notification', ToastNotification);
class ToastContainer extends BaseComponent {
init() {
this.addClass('toast-container');
}
show(message, type = 'info', duration = 4000) {
const toast = document.createElement('toast-notification');
toast.setAttribute('message', message);
toast.setAttribute('type', type);
toast.setAttribute('duration', duration.toString());
this.appendChild(toast);
return toast;
}
success(message, duration = 4000) {
return this.show(message, 'success', duration);
}
error(message, duration = 5000) {
return this.show(message, 'error', duration);
}
warning(message, duration = 4000) {
return this.show(message, 'warning', duration);
}
info(message, duration = 4000) {
return this.show(message, 'info', duration);
}
clearAll() {
this.innerHTML = '';
}
}
customElements.define('toast-container', ToastContainer);
export { ToastNotification, ToastContainer };
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview User Avatar Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Displays user avatar with fallback to initials
* @keywords avatar, user, profile, image, picture
*/
import { BaseComponent } from './base-component.js';
import { buildAvatarUrl } from '../utils/url.js';
class UserAvatar extends BaseComponent {
static get observedAttributes() {
return ['avatar', 'username', 'size', 'user-id'];
}
init() {
this.render();
}
render() {
const avatarData = this.getAttr('avatar');
const username = this.getAttr('username') || '';
const size = this.getAttr('size') || 'medium';
const userId = this.getAttr('user-id');
this.addClass('avatar', `avatar-${size}`);
let avatar = null;
if (avatarData) {
try {
avatar = JSON.parse(avatarData);
} catch (e) {
avatar = null;
}
}
const bgColor = avatar?.b || '#54556e';
const imageUrl = buildAvatarUrl(avatar);
if (imageUrl) {
this.setHtml(`
<div class="avatar-wrapper" style="background-color: ${bgColor}">
<img class="avatar-image" src="${imageUrl}" alt="${username}" loading="lazy">
</div>
`);
} else {
const initials = this.getInitials(username);
this.setHtml(`
<div class="avatar-wrapper avatar-initials" style="background-color: ${bgColor}">
<span>${initials}</span>
</div>
`);
}
if (userId || username) {
this.style.cursor = 'pointer';
this.setAttribute('role', 'button');
this.setAttribute('tabindex', '0');
}
}
getInitials(username) {
if (!username) return '?';
return username.substring(0, 2).toUpperCase();
}
onAttributeChanged(name, oldValue, newValue) {
this.render();
}
onConnected() {
this.on(this, 'click', this.handleClick);
this.on(this, 'keydown', this.handleKeydown);
}
handleClick(e) {
const username = this.getAttr('username');
if (username) {
this.getRouter()?.goToUser(username);
}
}
handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleClick(e);
}
}
setAvatar(avatar, username) {
if (avatar && typeof avatar === 'object') {
this.setAttr('avatar', JSON.stringify(avatar));
}
if (username) {
this.setAttr('username', username);
}
this.render();
}
}
customElements.define('user-avatar', UserAvatar);
export { UserAvatar };
+224
View File
@@ -0,0 +1,224 @@
/**
* @fileoverview User Profile Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Complete user profile display with stats and content
* @keywords user, profile, stats, about, content
*/
import { BaseComponent } from './base-component.js';
import { formatDate } from '../utils/date.js';
class UserProfile extends BaseComponent {
static get observedAttributes() {
return ['username', 'user-id'];
}
init() {
this.profileData = null;
this.isLoading = false;
this.activeTab = 'rants';
this.render();
this.bindEvents();
}
async load(username) {
if (!username) return;
this.isLoading = true;
this.setAttr('username', username);
this.render();
try {
const result = await this.getApi()?.getProfileByUsername(username);
if (result?.success) {
this.profileData = result.profile;
}
} catch (error) {
this.profileData = null;
} finally {
this.isLoading = false;
this.render();
}
}
render() {
if (this.isLoading) {
this.setHtml(`
<div class="profile-loading">
<loading-spinner text="Loading profile..."></loading-spinner>
</div>
`);
return;
}
if (!this.profileData) {
this.setHtml(`
<div class="profile-error">
<p>User not found</p>
<button class="btn btn-primary back-btn">Go Back</button>
</div>
`);
return;
}
const profile = this.profileData;
const rants = profile.content?.content?.rants || [];
const comments = profile.content?.content?.comments || [];
this.addClass('user-profile');
this.setHtml(`
<header class="profile-header">
<button class="back-btn" aria-label="Go back">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button>
<div class="profile-avatar">
<user-avatar
avatar='${JSON.stringify(profile.avatar)}'
username="${profile.username}"
size="large">
</user-avatar>
</div>
<div class="profile-info">
<h1 class="profile-username">${profile.username}</h1>
<div class="profile-score">+${profile.score}</div>
</div>
</header>
<section class="profile-details">
${profile.about ? `
<div class="detail-item">
<span class="detail-label">About</span>
<p class="detail-value">${profile.about}</p>
</div>
` : ''}
${profile.location ? `
<div class="detail-item">
<span class="detail-label">Location</span>
<span class="detail-value">${profile.location}</span>
</div>
` : ''}
${profile.skills ? `
<div class="detail-item">
<span class="detail-label">Skills</span>
<span class="detail-value">${profile.skills}</span>
</div>
` : ''}
${profile.github ? `
<div class="detail-item">
<span class="detail-label">GitHub</span>
<a class="detail-value detail-link" href="https://github.com/${profile.github}" target="_blank" rel="noopener">${profile.github}</a>
</div>
` : ''}
${profile.website ? `
<div class="detail-item">
<span class="detail-label">Website</span>
<a class="detail-value detail-link" href="${profile.website}" target="_blank" rel="noopener">${profile.website}</a>
</div>
` : ''}
<div class="detail-item">
<span class="detail-label">Joined</span>
<span class="detail-value">${formatDate(profile.created_time)}</span>
</div>
</section>
<section class="profile-content">
<div class="content-tabs">
<button class="tab ${this.activeTab === 'rants' ? 'active' : ''}" data-tab="rants">
Rants (${rants.length})
</button>
<button class="tab ${this.activeTab === 'comments' ? 'active' : ''}" data-tab="comments">
Comments (${comments.length})
</button>
</div>
<div class="content-panel">
${this.activeTab === 'rants' ? `
<div class="rants-list">
${rants.length > 0 ? rants.map(rant => `
<rant-card rant-id="${rant.id}"></rant-card>
`).join('') : '<p class="empty-message">No rants yet</p>'}
</div>
` : ''}
${this.activeTab === 'comments' ? `
<div class="comments-list">
${comments.length > 0 ? comments.map(comment => `
<div class="profile-comment" data-rant-id="${comment.rant_id}">
<rant-content text="${this.escapeAttr(comment.body)}"></rant-content>
<div class="comment-meta">
<span class="comment-score">+${comment.score}</span>
</div>
</div>
`).join('') : '<p class="empty-message">No comments yet</p>'}
</div>
` : ''}
</div>
</section>
`);
this.initRantCards(rants);
}
escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
initRantCards(rants) {
const cards = this.$$('rant-card');
cards.forEach((card, index) => {
if (rants[index]) {
card.setRant(rants[index]);
}
});
}
bindEvents() {
this.on(this, 'click', this.handleClick);
}
handleClick(e) {
const backBtn = e.target.closest('.back-btn');
const tab = e.target.closest('.tab');
const profileComment = e.target.closest('.profile-comment');
if (backBtn) {
e.preventDefault();
window.history.back();
return;
}
if (tab) {
this.activeTab = tab.dataset.tab;
this.render();
const rants = this.profileData?.content?.content?.rants || [];
this.initRantCards(rants);
return;
}
if (profileComment) {
const rantId = profileComment.dataset.rantId;
if (rantId) {
this.getRouter()?.goToRant(rantId);
}
}
}
onAttributeChanged(name, oldValue, newValue) {
if (name === 'username' && newValue && oldValue !== newValue) {
this.load(newValue);
}
}
getUsername() {
return this.profileData?.username || this.getAttr('username');
}
}
customElements.define('user-profile', UserProfile);
export { UserProfile };
+113
View File
@@ -0,0 +1,113 @@
/**
* @fileoverview Vote Buttons Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Upvote and downvote controls for rants and comments
* @keywords vote, upvote, downvote, score, rating
*/
import { BaseComponent } from './base-component.js';
class VoteButtons extends BaseComponent {
static get observedAttributes() {
return ['score', 'vote-state', 'type', 'item-id', 'disabled'];
}
init() {
this.render();
this.bindEvents();
}
render() {
const score = parseInt(this.getAttr('score') || '0', 10);
const voteState = parseInt(this.getAttr('vote-state') || '0', 10);
const disabled = this.hasAttr('disabled');
this.addClass('vote-buttons');
this.setHtml(`
<button class="vote-btn upvote ${voteState === 1 ? 'active' : ''}"
aria-label="Upvote"
${disabled ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
</svg>
</button>
<span class="vote-score ${voteState === 1 ? 'positive' : ''} ${voteState === -1 ? 'negative' : ''}">${score}</span>
<button class="vote-btn downvote ${voteState === -1 ? 'active' : ''}"
aria-label="Downvote"
${disabled ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/>
</svg>
</button>
`);
}
bindEvents() {
this.on(this, 'click', this.handleClick);
}
handleClick(e) {
const btn = e.target.closest('.vote-btn');
if (!btn || btn.disabled) return;
if (!this.isLoggedIn()) {
this.getRouter()?.goToLogin();
return;
}
const currentState = parseInt(this.getAttr('vote-state') || '0', 10);
let newVote;
if (btn.classList.contains('upvote')) {
newVote = currentState === 1 ? 0 : 1;
} else if (btn.classList.contains('downvote')) {
newVote = currentState === -1 ? 0 : -1;
}
if (newVote !== undefined) {
this.emit('vote', {
vote: newVote,
type: this.getAttr('type'),
itemId: this.getAttr('item-id')
});
}
}
setScore(score) {
this.setAttr('score', score.toString());
const scoreEl = this.$('.vote-score');
if (scoreEl) {
scoreEl.textContent = score;
}
}
setVoteState(state) {
this.setAttr('vote-state', state.toString());
this.render();
}
updateVote(score, voteState) {
this.setAttr('score', score.toString());
this.setAttr('vote-state', voteState.toString());
this.render();
}
disable() {
this.setAttr('disabled', '');
this.render();
}
enable() {
this.removeAttribute('disabled');
this.render();
}
onAttributeChanged(name, oldValue, newValue) {
this.render();
}
}
customElements.define('vote-buttons', VoteButtons);
export { VoteButtons };
+97
View File
@@ -0,0 +1,97 @@
/**
* @fileoverview YouTube Embed Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Embeds YouTube videos with preview thumbnail
* @keywords youtube, video, embed, media, player
*/
import { BaseComponent } from './base-component.js';
import { getYoutubeVideoId, getYoutubeThumbnail, getYoutubeEmbedUrl } from '../utils/url.js';
class YoutubeEmbed extends BaseComponent {
static get observedAttributes() {
return ['url', 'video-id'];
}
init() {
this.isPlaying = false;
this.render();
this.bindEvents();
}
render() {
let videoId = this.getAttr('video-id');
const url = this.getAttr('url');
if (!videoId && url) {
videoId = getYoutubeVideoId(url);
}
if (!videoId) {
this.setHtml('');
return;
}
this.addClass('youtube-embed');
if (this.isPlaying) {
const embedUrl = getYoutubeEmbedUrl(videoId);
this.setHtml(`
<div class="youtube-player">
<iframe src="${embedUrl}?autoplay=1&rel=0"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
`);
} else {
const thumbnail = getYoutubeThumbnail(videoId);
this.setHtml(`
<div class="youtube-preview">
<img class="youtube-thumbnail" src="${thumbnail}" alt="Video thumbnail" loading="lazy">
<button class="youtube-play" aria-label="Play video">
<svg viewBox="0 0 68 48" width="68" height="48">
<path fill="#f00" d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"/>
<path fill="#fff" d="M45 24L27 14v20"/>
</svg>
</button>
<span class="youtube-badge">YouTube</span>
</div>
`);
}
}
bindEvents() {
this.on(this, 'click', this.handleClick);
}
handleClick(e) {
const playBtn = e.target.closest('.youtube-play');
const preview = e.target.closest('.youtube-preview');
if (playBtn || preview) {
e.preventDefault();
this.play();
}
}
play() {
this.isPlaying = true;
this.render();
}
stop() {
this.isPlaying = false;
this.render();
}
onAttributeChanged(name, oldValue, newValue) {
this.isPlaying = false;
this.render();
}
}
customElements.define('youtube-embed', YoutubeEmbed);
export { YoutubeEmbed };