diff --git a/css/components/block.css b/css/components/block.css new file mode 100644 index 0000000..b920897 --- /dev/null +++ b/css/components/block.css @@ -0,0 +1,157 @@ +.blocked-users-manager { + display: block; +} + +.block-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.block-header h3 { + margin: 0; + font-size: 1rem; + color: var(--color-text); +} + +.block-count { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.block-add-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.block-input { + flex: 1; + padding: 0.625rem 0.875rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); + font-size: 0.875rem; +} + +.block-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2); +} + +.block-input::placeholder { + color: var(--color-text-muted); +} + +.block-add-btn { + flex-shrink: 0; +} + +.blocked-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 300px; + overflow-y: auto; +} + +.blocked-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--color-surface); + border-radius: var(--radius-md); +} + +.blocked-item .blocked-username { + font-size: 0.875rem; + color: var(--color-text); + font-weight: 500; +} + +.blocked-empty { + text-align: center; + padding: 2rem; + color: var(--color-text-muted); +} + +.blocked-empty p { + margin: 0; +} + +.block-actions { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.rant-detail-blocked { + padding: 1rem; +} + +.rant-detail-blocked .detail-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; +} + +.blocked-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: var(--color-text-secondary); +} + +.blocked-message svg { + color: var(--color-error); + margin-bottom: 1rem; + opacity: 0.7; +} + +.blocked-message p { + margin: 0.25rem 0; +} + +.blocked-message .blocked-username { + font-weight: 600; + color: var(--color-text); + margin-bottom: 1rem; +} + +.blocked-message .unblock-btn { + margin-top: 1rem; +} + +.profile-blocked-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid var(--color-error); + border-radius: var(--radius-md); + color: var(--color-error); + margin: 0 1rem 1rem; + font-size: 0.875rem; +} + +.profile-blocked-banner svg { + flex-shrink: 0; +} + +.block-user-btn { + margin-top: 0.5rem; +} + +.btn-small { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} diff --git a/index.html b/index.html index 67d269e..ce3b65d 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,7 @@ + diff --git a/js/app.js b/js/app.js index 8c758d3..5ecc457 100644 --- a/js/app.js +++ b/js/app.js @@ -10,6 +10,7 @@ import { StorageService } from './services/storage.js'; import { AuthService } from './services/auth.js'; import { Router } from './services/router.js'; import { ThemeService } from './services/theme.js'; +import { BlockService } from './services/block.js'; import { markdownRenderer } from './utils/markdown.js'; import { BaseComponent } from './components/base-component.js'; @@ -33,6 +34,7 @@ import { CommentForm } from './components/comment-form.js'; import { UserProfile } from './components/user-profile.js'; import { NotificationList, NotificationItem } from './components/notification-list.js'; import { PostForm, PostModal } from './components/post-form.js'; +import { BlockedUsersManager } from './components/blocked-users-manager.js'; import { HomePage } from './pages/home-page.js'; import { RantPage } from './pages/rant-page.js'; @@ -52,6 +54,7 @@ class Application { this.auth = new AuthService(this.api, this.storage); this.router = new Router(); this.theme = new ThemeService(this.storage); + this.block = new BlockService(this.storage); this.toast = null; this.header = null; this.nav = null; @@ -90,6 +93,7 @@ class Application { async init() { await this.auth.init(); this.theme.init(); + this.block.init(); await markdownRenderer.init(); this.setupToast(); diff --git a/js/components/blocked-users-manager.js b/js/components/blocked-users-manager.js new file mode 100644 index 0000000..50eae55 --- /dev/null +++ b/js/components/blocked-users-manager.js @@ -0,0 +1,111 @@ +import { BaseComponent } from './base-component.js'; + +class BlockedUsersManager extends BaseComponent { + init() { + this.blockChangeHandler = () => this.render(); + this.render(); + this.bindEvents(); + } + + render() { + const blockedUsers = this.getBlock()?.getBlockedUsers() || []; + + this.addClass('blocked-users-manager'); + + this.setHtml(` +
+

Blocked Users

+ ${blockedUsers.length} blocked +
+
+ + +
+
+ ${blockedUsers.length > 0 ? blockedUsers.map(username => ` +
+ @${username} + +
+ `).join('') : ` +
+

No blocked users

+
+ `} +
+ ${blockedUsers.length > 0 ? ` +
+ +
+ ` : ''} + `); + } + + bindEvents() { + this.on(this, 'submit', this.handleSubmit); + this.on(this, 'click', this.handleClick); + window.addEventListener('rantii:block-change', this.blockChangeHandler); + } + + onDisconnected() { + window.removeEventListener('rantii:block-change', this.blockChangeHandler); + } + + handleSubmit(e) { + e.preventDefault(); + const input = this.$('.block-input'); + if (!input) return; + + const username = input.value.trim().replace(/^@/, ''); + if (!username) return; + + const block = this.getBlock(); + if (block?.isBlocked(username)) { + window.app?.toast?.error(`@${username} is already blocked`); + return; + } + + if (block?.block(username)) { + window.app?.toast?.success(`Blocked @${username}`); + input.value = ''; + } + } + + handleClick(e) { + const unblockBtn = e.target.closest('.unblock-btn'); + const clearAllBtn = e.target.closest('.clear-all-btn'); + + if (unblockBtn) { + e.preventDefault(); + e.stopPropagation(); + const username = unblockBtn.dataset.username; + if (username && this.getBlock()?.unblock(username)) { + window.app?.toast?.success(`Unblocked @${username}`); + } + return; + } + + if (clearAllBtn) { + e.preventDefault(); + e.stopPropagation(); + if (confirm('Unblock all users?')) { + this.getBlock()?.clear(); + window.app?.toast?.success('All users unblocked'); + } + } + } + + getBlock() { + return window.app?.block; + } +} + +customElements.define('blocked-users-manager', BlockedUsersManager); + +export { BlockedUsersManager }; diff --git a/js/components/notification-list.js b/js/components/notification-list.js index d850a97..8f094ec 100644 --- a/js/components/notification-list.js +++ b/js/components/notification-list.js @@ -13,10 +13,21 @@ class NotificationList extends BaseComponent { this.notifications = []; this.isLoading = false; this.unreadCount = 0; + this.blockChangeHandler = () => this.render(); this.render(); this.bindEvents(); } + getBlock() { + return window.app?.block; + } + + getFilteredNotifications() { + const block = this.getBlock(); + if (!block) return this.notifications; + return block.filterNotifications(this.notifications); + } + async load() { if (!this.isLoggedIn()) { this.render(); @@ -62,7 +73,9 @@ class NotificationList extends BaseComponent { return; } - if (this.notifications.length === 0) { + const filteredNotifications = this.getFilteredNotifications(); + + if (filteredNotifications.length === 0) { this.setHtml(`
@@ -82,7 +95,7 @@ class NotificationList extends BaseComponent { ` : ''}
- ${this.notifications.map(notif => ` + ${filteredNotifications.map(notif => ` @@ -90,24 +103,21 @@ class NotificationList extends BaseComponent {
`); - this.initNotificationItems(); + this.initNotificationItems(filteredNotifications); } - initNotificationItems() { + initNotificationItems(filteredNotifications) { 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) {} + items.forEach((item, index) => { + if (filteredNotifications[index]) { + item.setNotification(filteredNotifications[index]); } }); } bindEvents() { this.on(this, 'click', this.handleClick); + window.addEventListener('rantii:block-change', this.blockChangeHandler); } handleClick(e) { @@ -139,6 +149,7 @@ class NotificationList extends BaseComponent { onDisconnected() { this.isLoading = false; + window.removeEventListener('rantii:block-change', this.blockChangeHandler); } getUnreadCount() { diff --git a/js/components/rant-detail.js b/js/components/rant-detail.js index bfcd18f..63cb17c 100644 --- a/js/components/rant-detail.js +++ b/js/components/rant-detail.js @@ -18,10 +18,27 @@ class RantDetail extends BaseComponent { this.rantData = null; this.comments = []; this.isLoading = false; + this.blockChangeHandler = () => this.render(); this.render(); this.bindEvents(); } + getBlock() { + return window.app?.block; + } + + getFilteredComments() { + const block = this.getBlock(); + if (!block) return this.comments; + return block.filterComments(this.comments); + } + + isRantBlocked() { + const block = this.getBlock(); + if (!block || !this.rantData) return false; + return block.isBlocked(this.rantData.user_username); + } + async load(rantId) { this.setAttr('rant-id', rantId); this.isLoading = true; @@ -69,6 +86,30 @@ class RantDetail extends BaseComponent { return; } + if (this.isRantBlocked()) { + this.setHtml(` +
+
+ +

Blocked User

+
+
+ + + +

This rant is from a blocked user

+

@${this.rantData.user_username}

+ +
+
+ `); + return; + } + const rant = this.rantData; const hasImage = rant.attached_image && typeof rant.attached_image === 'object'; const imageUrl = hasImage ? buildDevrantImageUrl(rant.attached_image.url) : null; @@ -131,7 +172,7 @@ class RantDetail extends BaseComponent {
- ${this.comments.map(comment => ` + ${this.getFilteredComments().map(comment => ` @@ -155,14 +196,11 @@ class RantDetail extends BaseComponent { } initComments() { + const filteredComments = this.getFilteredComments(); 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) {} + commentItems.forEach((item, index) => { + if (filteredComments[index]) { + item.setComment(filteredComments[index]); } }); } @@ -171,6 +209,11 @@ class RantDetail extends BaseComponent { this.on(this, 'click', this.handleClick); this.on(this, 'vote', this.handleVote); this.on(this, 'comment-posted', this.handleCommentPosted); + window.addEventListener('rantii:block-change', this.blockChangeHandler); + } + + onDisconnected() { + window.removeEventListener('rantii:block-change', this.blockChangeHandler); } handleClick(e) { @@ -178,6 +221,7 @@ class RantDetail extends BaseComponent { const username = e.target.closest('.author-username'); const avatar = e.target.closest('user-avatar'); const tag = e.target.closest('.tag'); + const unblockBtn = e.target.closest('.unblock-btn'); if (backBtn) { e.preventDefault(); @@ -185,6 +229,17 @@ class RantDetail extends BaseComponent { return; } + if (unblockBtn) { + e.preventDefault(); + e.stopPropagation(); + const usernameToUnblock = unblockBtn.dataset.username; + if (usernameToUnblock) { + this.getBlock()?.unblock(usernameToUnblock); + window.app?.toast?.success(`Unblocked @${usernameToUnblock}`); + } + return; + } + if (username || avatar) { this.getRouter()?.goToUser(this.rantData.user_username); return; diff --git a/js/components/rant-feed.js b/js/components/rant-feed.js index 95f5fcb..906c4ec 100644 --- a/js/components/rant-feed.js +++ b/js/components/rant-feed.js @@ -20,11 +20,22 @@ class RantFeed extends BaseComponent { this.hasMore = true; this.sort = this.getAttr('sort') || 'recent'; this.feedType = this.getAttr('feed-type') || 'rants'; + this.blockChangeHandler = () => this.render(); this.render(); this.bindEvents(); } + getBlock() { + return window.app?.block; + } + + getFilteredRants() { + const block = this.getBlock(); + if (!block) return this.rants; + return block.filterRants(this.rants); + } + async load(reset = false) { if (this.isLoading) return; if (!reset && !this.hasMore) return; @@ -98,7 +109,9 @@ class RantFeed extends BaseComponent { render() { this.addClass('rant-feed'); - if (this.rants.length === 0 && !this.isLoading) { + const filteredRants = this.getFilteredRants(); + + if (filteredRants.length === 0 && !this.isLoading) { this.setHtml(`

No rants found

@@ -116,7 +129,7 @@ class RantFeed extends BaseComponent {
- ${this.rants.map(rant => ` + ${filteredRants.map(rant => ` `).join('')}
@@ -132,14 +145,14 @@ class RantFeed extends BaseComponent { ` : ''} `); - this.initRantCards(); + this.initRantCards(filteredRants); } - initRantCards() { + initRantCards(filteredRants) { const cards = this.$$('rant-card'); cards.forEach((card, index) => { - if (this.rants[index]) { - card.setRant(this.rants[index]); + if (filteredRants[index]) { + card.setRant(filteredRants[index]); } }); } @@ -157,6 +170,7 @@ class RantFeed extends BaseComponent { bindEvents() { this.on(this, 'click', this.handleClick); this.setupInfiniteScroll(); + window.addEventListener('rantii:block-change', this.blockChangeHandler); } handleClick(e) { @@ -203,6 +217,7 @@ class RantFeed extends BaseComponent { } this.isLoading = false; this.hasMore = true; + window.removeEventListener('rantii:block-change', this.blockChangeHandler); } onAttributeChanged(name, oldValue, newValue) { diff --git a/js/components/user-profile.js b/js/components/user-profile.js index 92f253b..0cc019d 100644 --- a/js/components/user-profile.js +++ b/js/components/user-profile.js @@ -17,10 +17,21 @@ class UserProfile extends BaseComponent { this.profileData = null; this.isLoading = false; this.activeTab = 'rants'; + this.blockChangeHandler = () => this.render(); this.render(); this.bindEvents(); } + getBlock() { + return window.app?.block; + } + + isUserBlocked() { + const block = this.getBlock(); + if (!block || !this.profileData) return false; + return block.isBlocked(this.profileData.username); + } + async load(username) { if (!username) return; @@ -64,6 +75,8 @@ class UserProfile extends BaseComponent { const profile = this.profileData; const rants = profile.content?.content?.rants || []; const comments = profile.content?.content?.comments || []; + const isBlocked = this.isUserBlocked(); + const isOwnProfile = window.app?.auth?.getUsername()?.toLowerCase() === profile.username.toLowerCase(); this.addClass('user-profile'); @@ -84,8 +97,21 @@ class UserProfile extends BaseComponent {

${profile.username}

+${profile.score}
+ ${!isOwnProfile ? ` + + ` : ''}
+ ${isBlocked ? ` +
+ + + + This user is blocked +
+ ` : ''}
${profile.about ? `
@@ -179,13 +205,36 @@ class UserProfile extends BaseComponent { bindEvents() { this.on(this, 'click', this.handleClick); + window.addEventListener('rantii:block-change', this.blockChangeHandler); + } + + onDisconnected() { + window.removeEventListener('rantii:block-change', this.blockChangeHandler); } handleClick(e) { + const blockBtn = e.target.closest('.block-user-btn'); const backBtn = e.target.closest('.back-btn'); const tab = e.target.closest('.tab'); const profileComment = e.target.closest('.profile-comment'); + if (blockBtn) { + e.preventDefault(); + e.stopPropagation(); + const username = blockBtn.dataset.username; + if (username) { + const block = this.getBlock(); + if (block?.isBlocked(username)) { + block.unblock(username); + window.app?.toast?.success(`Unblocked @${username}`); + } else { + block?.block(username); + window.app?.toast?.success(`Blocked @${username}`); + } + } + return; + } + if (backBtn) { e.preventDefault(); window.history.back(); diff --git a/js/pages/settings-page.js b/js/pages/settings-page.js index 80b4670..d078df2 100644 --- a/js/pages/settings-page.js +++ b/js/pages/settings-page.js @@ -63,6 +63,10 @@ class SettingsPage extends BaseComponent {

Appearance

+
+

Blocked Users

+ +

About

diff --git a/js/services/auth.js b/js/services/auth.js index c43edbc..6cffa95 100644 --- a/js/services/auth.js +++ b/js/services/auth.js @@ -22,11 +22,27 @@ class AuthService { username: authData.username }; this.notifyAuthChange(); + this.fetchAndUpdateUsername(authData); return true; } return false; } + async fetchAndUpdateUsername(authData) { + if (!authData.userId) return; + try { + const profileResult = await this.api.getProfile(authData.userId); + if (profileResult?.success && profileResult.profile?.username) { + this.currentUser.username = profileResult.profile.username; + if (authData.username !== profileResult.profile.username) { + authData.username = profileResult.profile.username; + this.storage.setAuth(authData); + } + this.notifyAuthChange(); + } + } catch (e) {} + } + async login(username, password, remember = true) { const result = await this.api.login(username, password); if (result.success) { diff --git a/js/services/block.js b/js/services/block.js new file mode 100644 index 0000000..b2dc1f5 --- /dev/null +++ b/js/services/block.js @@ -0,0 +1,95 @@ +class BlockService { + constructor(storageService) { + this.storage = storageService; + this.blockedUsers = new Set(); + this.onBlockChange = null; + } + + init() { + const blocked = this.storage.getBlockedUsers(); + this.blockedUsers = new Set(blocked.map(u => u.toLowerCase())); + } + + isBlocked(username) { + if (!username) return false; + return this.blockedUsers.has(username.toLowerCase()); + } + + block(username) { + if (!username) return false; + const normalized = username.toLowerCase(); + if (this.blockedUsers.has(normalized)) return false; + this.blockedUsers.add(normalized); + this.save(); + this.notifyChange(); + return true; + } + + unblock(username) { + if (!username) return false; + const normalized = username.toLowerCase(); + if (!this.blockedUsers.has(normalized)) return false; + this.blockedUsers.delete(normalized); + this.save(); + this.notifyChange(); + return true; + } + + toggle(username) { + if (this.isBlocked(username)) { + return this.unblock(username); + } + return this.block(username); + } + + getBlockedUsers() { + return Array.from(this.blockedUsers).sort(); + } + + getBlockedCount() { + return this.blockedUsers.size; + } + + clear() { + this.blockedUsers.clear(); + this.save(); + this.notifyChange(); + } + + save() { + this.storage.setBlockedUsers(Array.from(this.blockedUsers)); + } + + notifyChange() { + if (this.onBlockChange) { + this.onBlockChange(this.getBlockedUsers()); + } + window.dispatchEvent(new CustomEvent('rantii:block-change', { + detail: { blockedUsers: this.getBlockedUsers() } + })); + } + + setBlockChangeCallback(callback) { + this.onBlockChange = callback; + } + + filterRants(rants) { + if (!rants || !Array.isArray(rants)) return []; + return rants.filter(rant => !this.isBlocked(rant.user_username)); + } + + filterComments(comments) { + if (!comments || !Array.isArray(comments)) return []; + return comments.filter(comment => !this.isBlocked(comment.user_username)); + } + + filterNotifications(notifications) { + if (!notifications || !Array.isArray(notifications)) return []; + return notifications.filter(notif => { + const username = notif.username?.name || notif.user_username?.name || notif.name; + return !this.isBlocked(username); + }); + } +} + +export { BlockService }; diff --git a/js/services/storage.js b/js/services/storage.js index 687658d..3db1732 100644 --- a/js/services/storage.js +++ b/js/services/storage.js @@ -133,6 +133,37 @@ class StorageService { return this.remove(`draft_comment_${rantId}`); } + getBlockedUsers() { + return this.get('blocked_users', []); + } + + setBlockedUsers(users) { + return this.set('blocked_users', users); + } + + addBlockedUser(username) { + const blocked = this.getBlockedUsers(); + const normalized = username.toLowerCase(); + if (!blocked.includes(normalized)) { + blocked.push(normalized); + return this.setBlockedUsers(blocked); + } + return true; + } + + removeBlockedUser(username) { + const blocked = this.getBlockedUsers(); + const normalized = username.toLowerCase(); + const filtered = blocked.filter(u => u !== normalized); + return this.setBlockedUsers(filtered); + } + + isUserBlocked(username) { + if (!username) return false; + const blocked = this.getBlockedUsers(); + return blocked.includes(username.toLowerCase()); + } + getCachedProfile(userId) { return this.get(`profile_cache_${userId}`, null); }