Initial commit.
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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, ''')}'>
|
||||
</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(/'/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 };
|
||||
@@ -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 };
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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, ''')}'>
|
||||
</comment-item>
|
||||
`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`);
|
||||
|
||||
this.initComments();
|
||||
}
|
||||
|
||||
escapeAttr(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
initComments() {
|
||||
const commentItems = this.$$('comment-item');
|
||||
commentItems.forEach(item => {
|
||||
const commentData = item.dataset.comment;
|
||||
if (commentData) {
|
||||
try {
|
||||
const comment = JSON.parse(commentData.replace(/'/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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user