1/** 2 * Community Feed Page 3 * 4 * Social feed for the Arturum filmmaking community. 5 * Features: feed tabs (Following/Discover/Trending), compose box, 6 * post cards (7 types), engagement (likes, comments, polls, bookmarks), 7 * sidebar (competitions, suggested creators, trending tags), 8 * infinite scroll, mobile swipe panels, and optimistic UI. 9 * 10 * @module pages/community 11 * @created 2026-02-12 12 */ 13 14import apiClient from '../api/client.js'; 15import { getOptimizedImageUrl, getOptimizedVideoPoster, getOptimizedAvatarUrl } from '../utils/media-helpers.js?v=1800000000'; 16import { isAuthenticated, getCurrentUser } from '../utils/auth.js'; 17import { showToast } from '../utils/toast.js'; 18import { MediaPickerModal } from '../components/MediaPickerModal.js'; 19import { t } from '../utils/i18n.js?v=1791300000'; 20 21// ============================================================================ 22// CONSTANTS 23// ============================================================================ 24 25const CIRCUMFERENCE = 100.53; // 2 * PI * 16 (ring radius) 26const FEED_POLL_INTERVAL = 30000; // 30s (auto-poll currently disabled) 27// Initial page = 10 posts. Scroll triggers next 10 well before the bottom is 28// in view (see initInfiniteScroll rootMargin) so the feed feels endless. 29const POSTS_PER_PAGE = 10; 30 31// SVG icons used across the page 32const ICONS = { 33 heart: '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>', 34 comment: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>', 35 share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/>', 36 bookmark: '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>', 37 remix: '<path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>', 38 play: '<polygon points="5 3 19 12 5 21 5 3"/>', 39 verified: '<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>', 40 more: '<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>', 41 image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>', 42 video: '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>', 43 audio: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>', 44 link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', 45 poll: '<path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/>', 46 bolt: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>', 47 close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 48 plus: '<circle cx="12" cy="12" r="10"/><path d="M12 8v8m-4-4h8"/>', 49 chevronDown: '<path d="M6 9l6 6 6-6"/>', 50 chevronLeft: '<path d="M15 18l-6-6 6-6"/>', 51 arrowRight: '<path d="M5 12h14m-7-7 7 7-7 7"/>', 52 trophy: '<path d="M6 9H4.5a2.5 2.5 0 010-5C7 4 7 7 7 7"/><path d="M18 9h1.5a2.5 2.5 0 000-5C17 4 17 7 17 7"/><path d="M4 22h16"/><path d="M10 22V8a3 3 0 00-3-3H5v4h3"/><path d="M14 22V8a3 3 0 013-3h2v4h-3"/>', 53 star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>', 54 userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/>', 55 tag: '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>', 56 model: '<circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M4.22 4.22l2.83 2.83m9.9 9.9 2.83 2.83M1 12h4m14 0h4M4.22 19.78l2.83-2.83m9.9-9.9 2.83-2.83"/>', 57}; 58 59function icon(name, { size = 24, fill = 'none', stroke = 'currentColor', strokeWidth = 2, cls = '' } = {}) { 60 return `<svg${cls ? ` class="${cls}"` : ''} width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}">${ICONS[name]}</svg>`; 61} 62 63// ============================================================================ 64// SEED / DEMO DATA â Matches prototype exactly 65// ============================================================================ 66 67const DEMO_POSTS = [ 68 { 69 id: 'demo-1', 70 type: 'showcase', 71 caption: 'Finally nailed this moody noir detective sequence after 12 iterations ð¬ The key was combining Kling\'s cinematic mode with a very specific lighting prompt. The leather texture on the couch came out incredibly realistic. Sharing the full prompt below â try it out!', 72 created_at: new Date(Date.now() - 2 * 3600000).toISOString(), 73 media: [{ id: 'm1', type: 'video', url: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=620&h=400&fit=crop', thumbnail_url: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=620&h=400&fit=crop', width: 620, height: 400, duration_ms: 10000 }], 74 author: { id: 1, username: 'nadiavolkov', display_name: 'Nadia Volkov', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=nadia', level: 312, xp: 9734400, is_verified: true, streak_days: 47 }, 75 generation_meta: { model: 'Kling 2.1 Pro', prompt: 'A noir detective sitting on a vintage leather couch, moody side lighting, cinematic 35mm film grain, aged Italian leather, Chesterfield tufting, warm specular highlights, detective noir atmosphere', settings: { duration: '10s', resolution: '1080p' } }, 76 linked_project: null, 77 poll: null, 78 engagement: { like_count: 247, comment_count: 38, remix_count: 12, share_count: 5, is_liked: true, is_bookmarked: false }, 79 comments_preview: [ 80 { id: 'c1', author: { username: 'MarcoFilms', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=marco', level: 89 }, body: 'The lighting is insane â what prompt did you use for the leather texture?', created_at: new Date(Date.now() - 2700000).toISOString(), like_count: 8 }, 81 { id: 'c2', author: { username: 'Luna_AI', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=luna', level: 45 }, body: 'Re-creating this with a cyberpunk palette ð¥', created_at: new Date(Date.now() - 3600000).toISOString(), like_count: 3 }, 82 ], 83 tags: ['#noir', '#cinematic', '#detective'], 84 }, 85 { 86 id: 'demo-2', 87 type: 'milestone', 88 milestone_title: 'ð First Competition Win!', 89 milestone_subtitle: 'Alex won Scene 3 of "Road to Freedom" with 847 votes â earned $720 in prize money', 90 caption: 'Can\'t believe it! My first win on Arturum ð Thank y
90ou to everyone who voted. This platform genuinely changed how I think about filmmaking. Prompt and settings shared on my profile if you want to try the same approach.', 91 created_at: new Date(Date.now() - 3 * 3600000).toISOString(), 92 media: [], 93 author: { id: 2, username: 'alexmakes', display_name: 'Alex Chen', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=alex', level: 89, xp: 792100, is_verified: false, streak_days: 0 }, 94 generation_meta: null, 95 linked_project: null, 96 poll: null, 97 engagement: { like_count: 392, comment_count: 64, remix_count: 0, share_count: 12, is_liked: false, is_bookmarked: false }, 98 comments_preview: [], 99 tags: [], 100 }, 101 { 102 id: 'demo-3', 103 type: 'promo', 104 caption: '"Road to Freedom" is 73% funded with 4 days left! 8-scene short film about escaping routine â entirely AI-generated. Every backer gets 30% revenue share forever. 3 scenes still need variants â jump in and compete! ð¬', 105 created_at: new Date(Date.now() - 5 * 3600000).toISOStr
105ing(), 106 media: [{ id: 'm3', type: 'image', url: 'https://images.unsplash.com/photo-1494783367193-149034c05e8f?w=620&h=350&fit=crop', thumbnail_url: 'https://images.unsplash.com/photo-1494783367193-149034c05e8f?w=620&h=350&fit=crop', width: 620, height: 350, duration_ms: null }], 107 author: { id: 2, username: 'alexmakes', display_name: 'Alex Chen', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=alex', level: 89, xp: 792100, is_verified: false, streak_days: 0 }, 108 generation_meta: null, 109 linked_project: { id: 101, title: 'Road to Freedom', category: 'Short Film', description: '8-scene collaborative AI short film about escaping routine â entirely AI-generated. Every backer gets 30% revenue share.', thumbnail_url: 'https://images.unsplash.com/photo-1494783367193-149034c05e8f?w=620&h=350&fit=crop', video_url: '', funding_pct: 73, backer_count: 47, deadline: '4 days left' }, 110 poll: null, 111 engagement: { like_count: 124, comment_count: 22, remix_count: 0, share_count: 8, is_liked: false, is_bookmarked: false }, 112 comments_preview: [], 113 tags: [], 114 }, 115 { 116 id: 'demo-4', 117 type: 'poll', 118 caption: 'Working on a sci-fi short and stuck between two color grades for the opening scene. Which feels more cinematic? Help me decide! ð¤', 119 created_at: new Date(Date.now() - 8 * 3600000).toISOString(), 120 media: [], 121 author: { id: 3, username: 'sophiaray', display_name: 'Sophia Ray', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sophia', level: 204, xp: 4161600, is_verified: false, streak_days: 12 }, 122 generation_meta: null, 123 linked_project: null, 124 poll: { options: [{ id: '0', label: 'Cold Blue â Blade Runner vibes', thumb_url: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=600&h=450&fit=crop', vote_count: 176 }, { id: '1', label: 'Warm Amber â Dune aesthetic', thumb_url: 'https://images.unsplash.com/photo-1534796636912-3b95b3ab5986?w=600&h=450&fit=crop', vote_count: 108 }], total_votes: 284, ends_at: new Date(Date.now() + 16 * 3600000).toISOString(), user_vote: null }, 125 engagement: { like_count: 89, comment_count: 15, remix_count: 0, share_count: 3, is_liked: false, is_bookmarked: false }, 126 comments_preview: [], 127 tags: [], 128 }, 129 { 130 id: 'demo-5', 131 type: 'bts', 132 caption: 'ð 15 iterations to get this right. The secret? Started with Veo 3.1 for the base motion, then used "Match This Shot" to refine with Kling Pro. Layering models is the real cheat code â each one has different strengths. Full breakdown in thread ð', 133 created_at: new Date(Date.now() - 12 * 3600000).toISOString(), 134 media: [{ id: 'm5', type: 'video', url: 'https://images.unsplash.com/photo-1485846234645-a62644f84728?w=620&h=400&fit=crop', thumbnail_url: 'https://images.unsplash.com/photo-1485846234645-a62644f84728?w=620&h=400&fit=crop', width: 620, height: 400, duration_ms: null }], 135 author: { id: 4, username: 'kaitanaka', display_name: 'Kai Tanaka', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=kai', level: 156, xp: 2433600, is_verified: false, streak_days: 0 }, 136 generation_meta: { model: 'VEO 3.1 â KLING PRO', prompt: 'Multi-model workflow: base motion from Veo 3.1, refined with Match This Shot + Kling Pro for final cinematic look', settings: {} }, 137 linked_project: null, 138 poll: null, 139 engagement: { like_count: 186, comment_count: 43, remix_count: 7, share_count: 15, is_liked: false, is_bookmarked: false }, 140 comments_preview: [], 141 tags: ['#bts', '#tutorial', '#multimodel'], 142 }, 143 { 144 id: 'demo-6', 145 type: 'showcase', 146 caption: 'Brought a 1920s jazz club to life with this. The saxophone player\'s fingers actually move to the beat ð· Took 3 tries to get the lighting right â that warm amber glow was the hardest part. Full generation settings in my profile.', 147 created_at: new Date(Date.now() - 18 * 3600000).toISOString(), 148 media: [{ id: 'm6', type: 'video', url: 'https://images.unsplash.com/photo-1415201364774-f6f0bb35f28f?w=620&h=400&fit=crop', thumbnail_url: 'https://images.unsplash.com/photo-1415201364774-f6f0bb35f28f?w=620&h=400&fit=crop', width: 620, height: 400, duration_ms: 8000 }], 149 author: { id: 5, username: 'katverse', display_name: 'Kat Versailles', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=kat', level: 278, xp: 7728400, is_verified: true, streak_days: 23 }, 150 generation_meta: { model: 'Kling 2.1 Pro', prompt: 'A saxophone player in a 1920s jazz club, warm amber lighting, cinematic close-up, fingers moving on keys, vintage atmosphere, film grain', settings: { duration: '8s', resolution: '1080p' } }, 151 linked_project: null, 152 poll: null, 153 engagement: { like_count: 312, comment_count: 56, remix_count: 9, share_count: 24, is_liked: fal
153se, is_bookmarked: true }, 154 comments_preview: [ 155 { id: 'c6', author: { username: 'MartinLens', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=martin', level: 134 }, body: 'The finger movement is incredible â best hand motion I\'ve seen from Kling!', created_at: new Date(Date.now() - 16 * 3600000).toISOString(), like_count: 22 }, 156 ], 157 tags: ['#jazz', '#cinematic', '#1920s'], 158 }, 159]; 160 161const DEMO_COMPETITIONS = [ 162 { id: 'comp-1', name: 'Joker â Scene Remix Challenge', prize: '$720/scene', prize_amount: 504000, funding_current: 342700, category: 'ð¬ 7 scenes open', entry_count: 12, deadline: '2 days left', progress: 68 }, 163 { id: 'comp-2', name: 'Czech Tourism â 30s Ad Spot', prize: '$2,500', prize_amount: 250000, funding_current: 105000, category: 'ð¢ Brand Brief', entry_count: 8, deadline: '5 days left', progress: 42 }, 164 { id: 'comp-3', name: 'Love Stories Vol. 3', prize: '$1,800', prize_amount: 180000, funding_current: 153000, category: 'â¤ï¸ Romance', entry_count: 23, deadline: '1 week left', progress: 85 }, 165 { id: 'comp-4', name: 'Rivet Gun â Product Demo Reel', prize: '$3,200', prize_amount: 320000, funding_current: 70400, category: 'ð¢ Brand Brief', entry_count: 4, deadline: '10 days left', progress: 22 }, 166 { id: 'comp-5', name: 'Cyberpunk City â Best Opening Shot', prize: '$500', prize_amount: 50000, funding_current: 45500, category: 'ð Sci-Fi', entry_count: 31, deadline: '3 days left', progress: 91 }, 167 { id: 'comp-6', name: 'Road to Freedom â Short Film', prize: '$4,200', prize_amount: 420000, funding_current: 306600, category: 'ð¬ Drama', entry_count: 47, deadline: '2025-01-15', progress: 73 }, 168 { id: 'comp-7', name: 'Neon Dreams â Music Video', prize: '$1,500', prize_amount: 150000, funding_current: 150000, category: 'ðµ Music', entry_count: 19, deadline: '2025-12-01', progress: 100 }, 169]; 170 171const DEMO_SUGGESTED = [ 172 { id: 101, username: 'emmastyles', display_name: 'Emma Styles', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=emma', level: 267, xp: 7128900, follower_count: 5200, reason: 'Similar to creators you follow' }, 173 { id: 102, username: 'ravimakes', display_name: 'Ravi Patel', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=ravi', level: 142, xp: 2016400, follower_count: 3800, reason: 'Top creator in #cinematic' }, 174 { id: 103, username: 'yukivfx', display_name: 'Yuki Tanaka', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=yuki', level: 91, xp: 828100, follower_count: 2100, reason: 'Uses Kling Pro like you' }, 175 { id: 104, username: 'priyafilms', display_name: 'Priya Sharma', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=priya', level: 198, xp: 3920400, follower_count: 4100, reason: 'Won 3 competitions this month' }, 176 { id: 105, username: 'carlosvfx', display_name: 'Carlos Rivera', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=carlos', level: 67, xp: 448900, follower_count: 890, reason: 'Rising creator · ð¥ 30 day streak' }, 177 { id: 106, username: 'zhangwei', display_name: 'Zhang Wei', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhang', level: 341, xp: 11628100, follower_count: 7800, reason: 'Top 1% · Multi-model specialist' }, 178 { id: 107, username: 'sarahk', display_name: 'Sarah Klein', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sarah', level: 112, xp: 1254400, follower_count: 1500, reason: 'Active in Road to Freedom' }, 179 { id: 108, username: 'omarh', display_name: 'Omar Hassan', avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=omar', level: 175, xp: 3062500, follower_count: 3200, reason: 'Followed by Nadia Volkov' }, 180]; 181 182const DEMO_TRENDING = [ 183 { tag: 'cinematic', post_count: 2418, today_count: 340, velocity_change: 1 }, 184 { tag: 'noir', post_count: 892, today_count: 127, velocity_change: 1 }, 185 { tag: 'scifi', post_count: 743, today_count: 98, velocity_change: 1 }, 186 { tag: 'kling21', post_count: 621, today_count: 72, velocity_change: 0 }, 187 { tag: 'productad', post_count: 518, today_count: 63, velocity_change: 0 }, 188 { tag: 'veo31', post_count: 487, today_count: 89, velocity_change: 1 }, 189 { tag: 'bts', post_count: 412, today_count: 51, velocity_change: 0 }, 190 { tag: 'multimodel', post_count: 356, today_count: 44, velocity_change: 0 }, 191 { tag: 'horror', post_count: 298, today_count: 67, velocity_change: 1 }, 192 { tag: 'matchthisshot', post_count: 241, today_count: 38, velocity_change: 0 }, 193]; 194 195// ============================================================================ 196// STATE 197// ============================================================================ 198 199let state = { 200 activeTab: 'discover', 201 posts: [], 202 nextCursor: null, 203 isLoading: false, 204 pollInterval: null, 205 scrollObserver: null, 206 videoObserver: null, 207 activeSidebarTab: 'competitions', 208 pollComposerActive: false, 209 pollOptionCount: 2, 210 pollOptionMedia: {}, 211 container: null, 212 swipePanel: null, 213 selectedMedia: [], 214 selectedProjectId: null, 215 selectedProjectTitle: null, 216}; 217 218// Singleton media picker â created once, reused 219let mediaPicker = null; 220 221// Post data cache for Re-create (keyed by post id) 222const postDataCache = {}; 223 224// ============================================================================ 225// HELPERS 226// ============================================================================ 227 228function escapeHtml(str) { 229 if (str === null || str === undefined) return ''; 230 return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 231} 232 233function timeAgo(dateStr) { 234 const now = Date.now(); 235 const then = new Date(dateStr).getTime(); 236 const seconds = Math.floor((now - then) / 1000); 237 if (seconds < 60) return 'just now'; 238 const minutes = Math.floor(seconds / 60); 239 if (minutes < 60) return `${minutes}m`; 240 const hours = Math.floor(minutes / 60); 241 if (hours < 24) return `${hours}h`; 242 const days = Math.floor(hours / 24); 243 if (days < 7) return `${days}d`; 244 const weeks = Math.floor(days / 7); 245 if (weeks < 5) return `${weeks}w`; 246 return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 247} 248 249function formatCount(n) { 250 if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; 251 if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; 252 return String(n); 253} 254 255/** 256 * Calculate XP ring offset for avatar level ring 257 * 258 * @param {number} totalXp - Total XP 259 * @param {number} level - Current level 260 * @returns {number} stroke-dashoffset value 261 */ 262function calcRingOffset(totalXp, level) { 263 const xpForCurrentLevel = level * level * 100; 264 const xpForNextLevel = (level + 1) * (level + 1) * 100; 265 const xpInLevel = totalXp - xpForCurrentLevel; 266 const xpNeeded = xpForNextLevel - xpForCurrentLevel; 267 const progress = xpNeeded > 0 ? Math.min(xpInLevel / xpNeeded, 1) : 0; 268 return CIRCUMFERENCE * (1 - progress); 269} 270 271// ============================================================================ 272// AVATAR RING COMPONENT 273// ============================================================================ 274 275/** 276 * Create avatar ring HTML with level/XP progress 277 * 278 * @param {Object} opts 279 * @param {string} opts.avatarUrl - Avatar image URL 280 * @param {number} opts.level - User level 281 * @param {number} opts.xp - Total XP 282 * @param {string} opts.size - 'sm' | 'md' | 'lg' | 'xl' 283 * @param {boolean} opts.showBadge - Whether to show level badge 284 * @param {boolean} opts.clickable - Whether avatar is clickable 285 * @returns {string} HTML string 286 */ 287function avatarRing({ avatarUrl, username = '', level = 0, xp = 0, size = 'lg', showBadge = true, clickable = false }) { 288 const offset = calcRingOffset(xp, level); 289 const sw = size === 'sm' ? '2.5' : '2'; 290 // Try the persona's avatar first, fall back to dicebear when missing OR 291 // when the helper returns '' (video mp4 avatar â can't render as <img>). 292 const sizePx = size === 'sm' ? 56 : (size === 'md' ? 88 : 128); 293 const dicebear = username ? `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(username)}` : ''; 294 const imgSrc = (avatarUrl ? getOptimizedAvatarUrl(avatarUrl, sizePx) : '') || dicebear; 295 return ` 296 <div class="avatar-ring size-${size}"${clickable ? ' style="cursor:pointer"' : ''}> 297 <svg class="ring-svg" viewBox="0 0 36 36"><circle class="ring-bg" cx="18" cy="18" r="16" stroke-width="${sw}"/><circle class="ring-fill" cx="18" cy="18" r="16" stroke-width="${sw}" stroke-dasharray="${CIRCUMFERENCE}" stroke-dashoffset="${offset}"/></svg> 298 <img class="avatar-img" src="${imgSrc}" alt="" loading="lazy"> 299 ${showBadge && level >
299 0 ? `<span class="level-badge">Lv.${level}</span>` : ''} 300 </div>`; 301} 302 303// ============================================================================ 304// POST CARD RENDERER 305// ============================================================================ 306 307/** Recursively render a comment and its nested replies (up to maxDepth levels) */ 308function renderCommentThread(c, postId, depth = 0, maxDepth = 2) { 309 const canReply = depth < maxDepth; 310 const currentUser = getCurrentUser(); 311 const isOwn = currentUser && currentUser.username === c.author.username; 312 const repliesHtml = (c.replies || []).map(r => 313 renderCommentThread(r, postId, depth + 1, maxDepth) 314 ).join(''); 315 316 const commentInner = ` 317 <div class="comment-item" data-comment-id="${c.id}"> 318 <a class="comment-avatar-link" href="/profile/${encodeURIComponent(c.author.username)}" data-username="${escapeHtml(c.author.username)}"> 319 ${avatarRing({ avatarUrl: c.author.avatar_url, username: c.author.username, level: c.author.level || 0, xp: 0, size: 'sm', showBadge: false })} 320 </a> 321 <div class="comment-body"> 322 <a class="comment-author" href="/profile/${encodeURIComponent(c.author.username)}" data-username="${escapeHtml(c.author.username)}">${escapeHtml(c.author.username)}</a> 323 <span class="comment-text"> ${escapeHtml(c.body).replace(/\n/g, '<br>')}</span> 324 <div class="comment-meta"> 325 <span>${timeAgo(c.created_at)}</span> 326 <span class="comment-like-btn${c.is_liked ? ' liked' : ''}" data-comment-id="${c.id}">⥠${c.like_count || 0}</span> 327 ${canReply ? `<span class="comment-reply-btn" data-comment-id="${c.id}" data-post-id="${postId}">Reply</span>` : ''} 328 ${isOwn ? `<span class="comment-delete-btn" data-comment-id="${c.id}" data-post-id="${postId}">Delete</span>` : ''} 329 </div> 330 <div class="comment-replies" data-parent-id="${c.id}">${repliesHtml}</div> 331 </div> 332 </div>`; 333 334 // Top-level comments (depth 0) are not wrapped in .comment-reply 335 if (depth === 0) return commentInner; 336 return `<div class="comment-reply" data-comment-id="${c.id}">${commentInner}</div>`; 337} 338 339// Safe per-post render â one bad post (malformed media URL, missing fields) 340// must NEVER take down the whole feed. If the full renderer throws, we still 341// emit a VISIBLE minimal card with the caption + author + a working media 342// link so the user can see their post even when something downstream is 343// broken. Hiding the failure (display:none) is the wrong move because then 344// the user thinks the feed only has 2 posts â that was the actual symptom. 345function safeRenderPostCard(post) { 346 try { 347 return renderPostCard(post); 348 } catch (err) { 349 const pid = post && post.id != null ? post.id : '?'; 350 console.warn('[community] renderPostCard failed for post', pid, err && err.message, err); 351 const caption = post && post.caption ? String(post.caption) : ''; 352 const authorName = post && post.author && (post.author.display_name || post.author.username) || 'Unknown'; 353 const safeCaption = caption.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 354 const safeAuthor = authorName.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 355 // Best-effort first media link (image or video URL) so the post is still 356 // viewable even though the full card rendering crashed. 357 let mediaLink = ''; 358 try { 359 const m = post && post.media && post.media[0]; 360 if (m && m.url) { 361 const safeUrl = String(m.url).replace(/"/g, '"'); 362 mediaLink = m.type === 'video' 363 ? `<video controls preload="metadata" muted playsinline style="width:100%;max-height:480px;display:block;border-radius:8px;background:#000"><source src="${safeUrl}"></video>` 364 : `<img src="${safeUrl}" alt="" style="width:100%;display:block;border-radius:8px">`; 365 } 366 } catch (_) { /* ignore â media is optional */ } 367 return `<article class="post-card post-card-fallback" data-post-id="${pid}" style="border:1px solid rgba(220,38,38,0.4);padding:16px;margin-bottom:12px;border-radius:12px;background:rgba(255,255,255,0.03)"> 368 <div style="font-weight:600;margin-bottom:6px">${safeAuthor}</div> 369 ${safeCaption ? `<div style="white-space:pre-wrap;margin-bottom:10px;color:#ddd">${safeCaption}</div>` : ''} 370 ${mediaLink} 371 </article>`; 372 } 373} 374 375function renderPostCard(post) { 376 const { id, type, caption, created_at, media, author, generation_meta, linked_project, poll, engagement, comments_preview, tags } = post; 377 378 // Cache post data for Re-create & Share features 379 postDataCache[id] = { media, generation_meta, caption }; 380 381 // Header 382 const verifiedBadge = author.is_verified 383 ? `<svg class="post-verified" width="14" height="14" viewBox="0 0 24 24" fill="#DC2626">${ICONS.verified}</svg>` 384 : ''; 385 const streakBadge = author.streak_days > 0 386 ? `<span class="creator-streak-badge">ð¥ ${author.streak_days} day${author.streak_days !== 1 ? 's' : ''}</span>` 387 : ''; 388 389 // Type label 390 let typeLabel = ''; 391 if (type === 'bts') typeLabel = '<span class="post-type-label bts">Behind the Scenes</span>'; 392 else if (type === 'promo') typeLabel = '<span class="post-type-label promo">Project</span>'; 393 394 // Project link for promo 395 let projectLink = ''; 396 if (linked_project) { 397 projectLink = `<span class="post-project-link">
397${icon('link', { size: 10, fill: 'currentColor', stroke: 'none' })} ${escapeHtml(linked_project.title)}</span>`; 398 } 399 400 const header = ` 401 <div class="post-header"> 402 <a class="post-author-link" href="/profile/${encodeURIComponent(author.username)}" data-username="${escapeHtml(author.username)}"> 403 ${avatarRing({ avatarUrl: author.avatar_url, username: author.username, level: author.level, xp: author.xp, size: 'lg', clickable: true })} 404 <div class="post-author-info"> 405 <div class="post-author-row"> 406 <span class="post-author-name">${escapeHtml(author.display_name || author.username)}</span> 407 ${verifiedBadge} 408 ${streakBadge ? `<div class="post-creator-badges">${streakBadge}</div>` : ''} 409 </div> 410 <div class="post-meta-row"> 411 <span class="post-time">${timeAgo(created_at)}</span> 412 ${typeLabel} 413 ${projectLink} 414 </div> 415 </div> 416 </a> 417 <button class="post-more-btn" data-post-id="${id}" data-author-id="${author.id}">${icon('more', { size: 18, fill: 'currentColor', stroke: 'none', strokeWidth: 0 })}</button> 418 </div>`; 419 420 // Milestone banner 421 let milestoneBanner = ''; 422 if (type === 'milestone' && post.milestone_title) { 423 milestoneBanner = ` 424 <div class="milestone-banner"> 425 <div class="milestone-icon">${icon('trophy', { size: 24, stroke: '#0a0a0a', strokeWidth: 2.5 })}</div> 426 <div class="milestone-text"> 427 <h4>${escapeHtml(post.milestone_title)}</h4> 428 <p>${escapeHtml(post.milestone_subtitle || '')}</p> 429 </div> 430 </div>`; 431 } 432 433 // Caption 434 const captionHtml = caption 435 ? `<div class="post-caption"><div class="caption-text">${escapeHtml(caption).replace(/\n/g, '<br>')}</div></div>` 436 : ''; 437 438 // Media 439 let mediaHtml = ''; 440 if (media && media.length > 0) { 441 let modelBadge = ''; 442 if (generation_meta && generation_meta.model) { 443 modelBadge = `<span class="media-model-badge">${escapeHtml(generation_meta.model.toUpperCase())}</span>`; 444 } 445 if (type === 'bts' && generation_meta) { 446 modelBadge = `<span class="media-model-badge">${escapeHtml(generation_meta.model || 'Multi-model')}</span>`; 447 } 448 449 // Check if first item is audio â Suno-style music card (matches /create page) 450 if (media[0].type === 'audio') { 451 const audioCards = media.filter(m => m.type === 'audio').map((m, idx) => { 452 const durationMs = m.duration_ms || 0; 453 const durationStr = durationMs ? formatDuration(durationMs) : ''; 454 const hasArt = !!m.thumbnail_url; 455 const artSrc = hasArt ? (getOptimizedImageUrl(m.thumbnail_url, 'gallery') || m.thumbnail_url) : ''; 456 const expandBtn = hasArt ? `<button class="post-media-expand" data-src="${escapeHtml(m.thumbnail_url)}" title="View full size"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>` : ''; 457 // Song title/prompt/model â prefer per-item data, fall back to generation_meta for first item 458 const songTitle = m.title || (idx === 0 ? generation_meta?.title : null) || ''; 459 const songPrompt = m.prompt || (idx === 0 ? generation_meta?.prompt : null) || ''; 460 const songModel = m.model || (idx === 0 ? generation_meta?.model : null) || ''; 461 const isSuno = /suno/i.test(songModel); 462 const tagsStr = isSuno ? 'Suno AI' : (songModel || ''); 463 return ` 464 <div class="post-audio-card audio-carousel-slide${idx === 0 ? ' active' : ''}" data-index="${idx}" data-post-id="${id}" data-duration-ms="${durationMs}"> 465 <div class="audio-card-cover"> 466 ${hasArt 467 ? `<img src="${artSrc}" alt="" loading="lazy" class="audio-card-img">` 468 : `<div class="audio-card-placeholder"><svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="rgba(220,38,38,0.4)" stroke-width="1.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></div>` 469 } 470 <div class="audio-card-play-overlay"> 471 <button class="post-audio-play-btn" data-src="${escapeHtml(m.url)}"> 472 <svg width="24" height="24" viewBox="0 0 24 24" fill="#fff" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg> 473 </button> 474 </div> 475 <div class="audio-card-hover-btns"> 476 <button class="audio-hover-btn audio-lyrics-btn" title="Lyrics">
477 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> 478 <span>Lyrics</span> 479 </button> 480 <button class="audio-hover-btn audio-download-btn" data-src="${escapeHtml(m.url)}" title="Download"> 481 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 482 <span>Download</span> 483 </button> 484 </div> 485 ${expandBtn} 486 </div> 487 <div class="audio-card-info"> 488 ${songTitle ? `<h4 class="audio-card-title">${escapeHtml(songTitle)}</h4>` : ''} 489 ${songPrompt ? `<p class="audio-card-desc">${escapeHtml(songPrompt)}</p>` : ''} 490 ${tagsStr ? `<p class="audio-card-tags">${escapeHtml(durationStr ? durationStr + ' ⢠' : '')}${escapeHtml(tagsStr)}</p>` : (durationStr ? `<p class="audio-card-tags">${escapeHtml(durationStr)}</p>` : '')} 491 <div class="audio-card-progress"> 492 <div class="audio-card-bar-fill"></div> 493 </div> 494 <div class="audio-card-controls"> 495 <button class="audio-card-pp" data-src="${escapeHtml(m.url)}"> 496 <svg class="pp-play" width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg> 497 <svg class="pp-pause" width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="none"><rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/></svg> 498 </button> 499 <div class="audio-card-time"> 500 <span class="audio-card-current">0:00</span> / <span class="audio-card-total">${durationStr || '0:00'}</span> 501 </div> 502 <button class="audio-card-vol"> 503 <svg class="vol-on" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> 504 <svg class="vol-off" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> 505 </button> 506 </div> 507 </div> 508 </div>`; 509 }); 510 511 if (audioCards.length === 1) { 512 mediaHtml = audioCards[0]; 513 } else { 514 // Multiple audio â carousel with arrows + dots 515 const dots = audioCards.map((_, idx) => 516 `<button class="audio-carousel-dot${idx === 0 ? ' active' : ''}" data-index="${idx}"></button>` 517 ).join(''); 518 mediaHtml = ` 519 <div class="audio-carousel" data-post-id="${id}" data-count="${audioCards.length}"> 520 <div class="audio-carousel-track">${audioCards.join('')}</div> 521 <button class="audio-carousel-arrow audio-carousel-prev" data-dir="-1" style="display:none"> 522 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg> 523 </button> 524 <button class="audio-carousel-arrow audio-carousel-next" data-dir="1"> 525 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg> 526 </button> 527 <div class="audio-carousel-dots">${dots}</div> 528 </div>`; 529 } 530 } else if (media.length === 1) { 531 // Single image or video â no carousel 532 const m = media[0]; 533 const aspectRatio = m.width && m.height ? `${m.width}/${m.height}` : '16/10'; 534 const durationStr = m.duration_ms ? formatDuration(m.duration_ms) : ''; 535 const expandIcon = `<button class="post-media-expand" data-src="${escapeHtml(m.url)}" title="View full size"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/>
535<line x1="3" y1="21" x2="10" y2="14"/></svg></button>`; 536 if (m.type === 'video') { 537 const posterUrl = m.thumbnail_url 538 ? (getOptimizedImageUrl(m.thumbnail_url, 'gallery') || m.thumbnail_url) 539 : (getOptimizedVideoPoster(m.url, 'gallery') || ''); 540 mediaHtml = ` 541 <div class="post-media post-media-video" data-post-id="${id}"> 542 <video data-src="${escapeHtml(m.url)}#t=0.5" poster="${posterUrl}" preload="none" playsinline loop 543 onmouseenter="this.muted=false" onmouseleave="this.muted=true" 544 style="${m.width && m.height ? `aspect-ratio:${aspectRatio};` : ''}width:100%;display:block;" class="lazy-video"></video> 545 <button class="video-mute-btn" title="Mute"> 546 <svg class="icon-muted" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> 547 <svg class="icon-unmuted" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> 548 </button> 549 <div class="post-media-overlay"> 550 ${durationStr ? `<span class="media-duration">${type === 'bts' ? 'BTS · ' : ''}${durationStr}</span>` : ''} 551 ${modelBadge} 552 </div> 553 ${expandIcon} 554 <div class="double-tap-heart"><svg width="80" height="80" viewBox="0 0 24 24" fill="#DC2626">${ICONS.heart}</svg></div> 555 </div>`; 556 } else { 557 const imgUrl = m.thumbnail_url || m.url; 558 const optimizedUrl = getOptimizedImageUrl(imgUrl, 'lightbox') || imgUrl || ''; 559 mediaHtml = ` 560 <div class="post-media" data-post-id="${id}"> 561 <img src="${optimizedUrl}" alt="" style="${m.width && m.height ? `aspect-ratio:${aspectRatio};` : ''}" loading="lazy"> 562 <div class="post-media-overlay"> 563 ${durationStr ? `<span class="media-duration">${durationStr}</span>` : ''} 564 ${modelBadge} 565 </div> 566 ${expandIcon} 567 <div class="double-tap-heart"><svg width="80" height="80" viewBox="0 0 24 24" fill="#DC2626">${ICONS.heart}</svg></div> 568 </div>`; 569 } 570 } else { 571 // Multiple media â carousel
572 const slides = media.map((m, idx) => { 573 const aspectRatio = m.width && m.height ? `${m.width}/${m.height}` : '16/10'; 574 const durationStr = m.duration_ms ? formatDuration(m.duration_ms) : ''; 575 const slideExpand = `<button class="post-media-expand" data-src="${escapeHtml(m.url)}" title="View full size"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>`; 576 if (m.type === 'video') { 577 const posterUrl = m.thumbnail_url 578 ? (getOptimizedImageUrl(m.thumbnail_url, 'gallery') || m.thumbnail_url) 579 : (getOptimizedVideoPoster(m.url, 'gallery') || ''); 580 return `<div class="carousel-slide${idx === 0 ? ' active' : ''}" data-index="${idx}"> 581 <video data-src="${escapeHtml(m.url)}#t=0.5" poster="${posterUrl}" preload="none" playsinline loop 582 onmouseenter="this.muted=false" onmouseleave="this.muted=true" 583 style="${m.width && m.height ? `aspect-ratio:${aspectRatio};` : ''}width:100%;display:block;" class="lazy-video"></video> 584 <button class="video-mute-btn" title="Mute"> 585 <svg class="icon-muted" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> 586 <svg class="icon-unmuted" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> 587 </button> 588 ${durationStr ? `<span class="media-duration" style="position:absolute;bottom:10px;left:52px;">${durationStr}</span>` : ''} 589 ${slideExpand} 590 </div>`; 591 } else { 592 const imgUrl = m.thumbnail_url || m.url; 593 const optimizedUrl = getOptimizedImageUrl(imgUrl, 'lightbox') || imgUrl || ''; 594 return `<div class="carousel-slide${idx === 0 ? ' active' : ''}" data-index="${idx}"> 595 <img src="${optimizedUrl}" alt="" style="${m.width && m.height ? `aspect-ratio:${aspectRatio};` : ''}" loading="lazy"> 596 ${slideExpand} 597 </div>`; 598 } 599 }).join(''); 600 601 const dots = media.map((_, idx) => 602 `<button class="carousel-dot${idx === 0 ? ' active' : ''}" data-index="${idx}"></button>` 603 ).join(''); 604 605 mediaHtml = ` 606 <div class="post-media post-carousel" data-post-id="${id}" data-count="${media.length}"> 607 <div class="carousel-track">${slides}</div> 608 <button class="carousel-arrow carousel-prev" data-dir="-1"> 609 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg> 610 </button> 611 <button class="carousel-arrow carousel-next" data-dir="1"> 612 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5"><polyline points="9 6 15 12 9 18"/></svg> 613 </button> 614 <div class="carousel-dots">${dots}</div> 615 <div class="carousel-counter">${1}/${media.length}</div> 616 <div class="post-media-overlay">${modelBadge}</div> 617 <div class="double-tap-heart"><svg width="80" height="80" viewBox="0 0 24 24" fill="#DC2626">${ICONS.heart}</svg></div> 618 </div>`; 619 } 620 } 621 622 // Try This Prompt 623 let tryPromptHtml = ''; 624 if (generation_meta && generation_meta.prompt && (type === 'showcase' || type === 'bts')) { 625 const promptPreview = generation_meta.prompt.length > 120 626 ? generation_meta.prompt.substring(0, 120) + '...' 627 : generation_meta.prompt; 628 tryPromptHtml = ` 629 <div class="post-try-prompt" data-post-id="${id}" data-prompt="${escapeHtml(generation_meta.prompt)}" data-model="${escapeHtml(generation_meta.model || '')}"> 630 <div class="try-prompt-icon">${icon('bolt', { size: 16, stroke: 'white' })}</div> 631 <div class="try-prompt-text"> 632 <div class="try-prompt-label">Try This Prompt</div> 633 <div class="try-prompt-preview">${escapeHtml(promptPreview)}</div> 634 </div> 635 ${icon('arrowRight', { size: 16, cls: 'try-prompt-arrow' })} 636 </div>`; 637 } 638 639 // Promo card 640 let promoCardHtml = ''; 641 if (linked_project) { 642 const fundingPct = linked_project.funding_pct || 0; 643 const fundingCents = linked_project.funding_cents || 0; 644 const fundingGoal = linked_project.funding_goal || 0; 645 const fundingDollars = Math.floor(fundingCents / 100); 646 const hasGoal = fundingGoal > 0; 647 const goalDollars = Math.floor(fundingGoal / 100); 648 649 // Hero media: prefer project video_url, fall back to thumbnail_url, then first post media 650 const heroVideoUrl = linked_project.video_url || ''; 651 const heroThumbUrl = linked_project.thumbnail_url || (media && media[0] ? (media[0].thumbnail_url || media[0].url) : ''); 652 const optimizedThumb = heroThumbUrl && (heroThumbUrl.includes('media.arturum.ai') || heroThumbUrl.includes('r2.dev')) 653 ? (getOptimizedImageUrl(heroThumbUrl, 'gallery') || '') : (heroThumbUrl || ''); 654 655 let heroMediaHtml = ''; 656 if (heroVideoUrl) { 657 heroMediaHtml = `<div class="promo-hero"> 658 <video data-src="${escapeHtml(heroVideoUrl)}#t=0.5" preload="none" muted playsinline loop style="width:100%;height:100%;object-fit:cover;" class="lazy-video"></video> 659 <div class="promo-play-btn"><svg width="28" height="28" viewBox="0 0 24 24" fill="white"><polygon points="8,5 19,12 8,19"/></svg></div> 660 </div>`; 661 } else if (optimizedThumb) { 662 heroMediaHtml = `<div class="promo-hero"><img src="${optimizedThumb}" alt="" loading="lazy"></div>`; 663 } 664 665 const categoryBadge = linked_project.category 666 ? `<span class="promo-category">${escapeHtml(linked_project.category)}</span>` : ''; 667 const descSnippet = linked_project.description 668 ? `<div class="promo-desc">${escapeHtml(linked_project.description.length > 120 ? linked_project.description.slice(0, 120) + 'â¦' : linked_project.description)}</div>` : ''; 669 670 // Funding display: show dollar amount + percentage if goal exists, or just dollar amount 671 let fundingLabel = ''; 672 if (hasGoal) { 673 fundingLabel = `<span class="stat-highlight">
673$${fundingDollars.toLocaleString()} / $${goalDollars.toLocaleString()} (${fundingPct}%)</span>`; 674 } else if (fundingDollars > 0) { 675 fundingLabel = `<span class="stat-highlight">$${fundingDollars.toLocaleString()} funded</span>`; 676 } 677 678 const barWidth = hasGoal ? Math.min(fundingPct, 100) : (fundingDollars > 0 ? 100 : 0); 679 680 promoCardHtml = ` 681 <div class="post-promo-card" data-project-id="${linked_project.id}" role="link" tabindex="0"> 682 ${heroMediaHtml} 683 <div class="promo-body"> 684 <div class="promo-body-top"> 685 <div class="promo-title-row"> 686 <div class="promo-title">${escapeHtml(linked_project.title)}</div> 687 ${categoryBadge} 688 </div> 689 ${descSnippet} 690 </div> 691 <div class="promo-body-bottom"> 692 ${fundingLabel || (linked_project.backer_count > 0) ? ` 693 <div class="promo-funding-bar"><div class="promo-funding-fill" style="width:${barWidth}%"></div></div> 694 <div class="promo-stats"> 695 ${fundingLabel} 696 ${linked_project.backer_count >
696 0 ? `<span>ð¥ ${linked_project.backer_count} backer${linked_project.backer_count !== 1 ? 's' : ''}</span>` : ''} 697 ${linked_project.deadline ? `<span class="stat-urgent">${escapeHtml(linked_project.deadline)}</span>` : ''} 698 </div>` : ''} 699 </div> 700 </div> 701 </div>`; 702 } 703 704 // Poll 705 let pollHtml = ''; 706 if (type === 'poll' && poll && poll.options) { 707 const totalVotes = poll.total_votes || 0; 708 const userVoted = poll.user_vote != null; 709 const hasMedia = poll.options.some(opt => opt.thumb_url); 710 711 const pollOptions = poll.options.map(opt => { 712 const pct = totalVotes > 0 ? Math.round((opt.vote_count / totalVotes) * 100) : 0; 713 const isUserVote = poll.user_vote === opt.id; 714 const voteCount = opt.vote_count || 0; 715 716 if (hasMedia) { 717 // ---- VISUAL CARD MODE ---- 718 let mediaHtml = ''; 719 if (opt.thumb_url) { 720 const isVideo = /\.(mp4|webm|mov)(\?|#|$)/i.test(opt.thumb_url); 721 const isAudio = /\.(mp3|wav|ogg|aac|m4a)(\?|#|$)/i.test(opt.thumb_url); 722 const isR2 = opt.thumb_url.includes('media.arturum.ai') || opt.thumb_url.includes('r2.dev'); 723 const expandBtn = `<button class="poll-card-expand" data-src="${escapeHtml(opt.thumb_url)}" title="View full size"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>`; 724 if (isVideo) { 725 mediaHtml = `<div class="poll-card-media"> 726 <video data-src="${escapeHtml(opt.thumb_url)}#t=0.5" preload="none" muted playsinline style="width:100%;height:100%;object-fit:cover;pointer-events:none;" class="lazy-video"></video> 727 <div class="poll-card-play"><svg width="20" height="20" viewBox="0 0 24 24" fill="white"><polygon points="8,5 19,12 8,19"/></svg></div> 728 ${expandBtn} 729 </div>`; 730 } else if (isAudio) { 731 mediaHtml = `<div class="poll-card-media poll-card-audio"> 732 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#DC2626" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> 733 ${expandBtn} 734 </div>`; 735 } else { 736 const optimizedSrc = isR2 ? (getOptimizedImageUrl(opt.thumb_url, 'gallery') || '') : opt.thumb_url; 737 mediaHtml = `<div class="poll-card-media"><img src="${optimizedSrc}" alt="" loading="lazy">${expandBtn}</div>`; 738 } 739 } else { 740 mediaHtml = `<div class="poll-card-media poll-card-empty"><span class="poll-card-number">${escapeHtml(opt.id ? String(Number(opt.id) + 1) : '?')}</span></div>`; 741 } 742 743 return ` 744 <div class="poll-card${isUserVote ? ' voted' : ''}" data-option-id="${escapeHtml(opt.id)}" data-post-id="${id}"> 745 ${mediaHtml} 746 <div class="poll-card-body"> 747 <span class="poll-card-label">${escapeHtml(opt.label)}</span> 748 <div class="poll-card-result" style="opacity:${userVoted ? 1 : 0}"> 749 <div class="poll-card-bar-track"><div class="poll-card-bar-fill" style="width:${userVoted ? pct : 0}%"></div></div> 750 <span class="poll-card-pct">${pct}%</span> 751 </div> 752 </div> 753 ${isUserVote ? '<div class="poll-card-check"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>' : ''} 754 </div>`; 755 } else { 756 // ---- TEXT-ONLY LIST MODE ---- 757 return ` 758 <div class="poll-option${isUserVote ? ' voted' : ''}" data-option-id="${escapeHtml(opt.id)}" data-post-id="${id}"> 759 <div class="poll-bar" style="width:${userVoted ? pct : 0}%"></div> 760 <div class="poll-option-content"> 761 <span class="poll-option-label">${escapeHtml(opt.label)}</span> 762 <span class="poll-option-pct" style="display:${userVoted ? 'inline' : 'none'}">${pct}%</span> 763 </div> 764 </div>`; 765 } 766 }).join(''); 767 768 const pollMeta = `${totalVotes} vote${totalVotes !== 1 ? 's' : ''}${poll.ends_at ? ` · ${timeAgo(poll.ends_at)} left` : ''}`; 769 const containerClass = hasMedia ? 'post-poll poll-visual' : 'post-poll'; 770 pollHtml = ` 771 <div class="${containerClass}${userVoted ? ' voted' : ''}" data-post-id="${id}"> 772 ${hasMedia ? `<div class="poll-cards-grid poll-cols-${Math.min(poll.options.length, 4)}">${pollOptions}</div>` : pollOptions} 773 <div class="poll-meta"> 774 ${icon('poll', { size: 12 })} 775 ${pollMeta} 776 </div> 777 </div>`; 778 } 779 780 // Tags 781 let tagsHtml = ''; 782 if (tags && tags.length > 0) { 783 const tagSpans = tags.map(t => `<span class="post-tag">${escapeHtml(t)}
783</span>`).join(''); 784 let modelTag = ''; 785 if (generation_meta && generation_meta.model) { 786 const settings = generation_meta.settings || {}; 787 const info = [generation_meta.model, settings.duration, settings.resolution].filter(Boolean).join(' · '); 788 modelTag = `<span class="post-model-tag">${icon('model', { size: 14 })}${escapeHtml(info)}</span>`; 789 } 790 tagsHtml = `<div class="post-tags">${tagSpans}${modelTag}</div>`; 791 } 792 793 // Engagement bar 794 const isLiked = engagement.is_liked; 795 const isBookmarked = engagement.is_bookmarked; 796 const engagementHtml = ` 797 <div class="post-engagement"> 798 <button class="engage-btn${isLiked ? ' liked like-animate' : ''}" data-action="like" data-post-id="${id}"> 799 <svg viewBox="0 0 24 24" fill="${isLiked ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="${isLiked ? '0' : '2'}">${ICONS.heart}</svg> 800 <span>${formatCount(engagement.like_count)}</span> 801 </button> 802 <button class="engage-btn" data-action="comment" data-post-id="${id}"> 803 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.comment}</svg> 804 <span>${formatCount(engagement.comment_count)}</span> 805 </button> 806 ${(type === 'showcase' || type === 'bts') && media && media.length > 0 && (generation_meta?.prompt || media.some(m => m.prompt)) ? ` 807 <button class="engage-btn remix-btn" data-action="remix" data-post-id="${id}"> 808 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.remix}</svg> 809 <span class="remix-label">Re-create</span> 810 </button>` : ''} 811 <button class="engage-btn" data-action="share" data-post-id="${id}"> 812 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.share}</svg> 813 <span>Share</span> 814 </button> 815 <div class="engage-spacer"></div> 816 <button class="engage-btn bookmark${isBookmarked ? ' bookmarked' : ''}" data-action="bookmark" data-post-id="${id}"> 817 <svg viewBox="0 0 24 24" fill="${isBookmarked ? '#EAB308' : 'none'}" stroke="${isBookmarked ? '#EAB308' : 'currentColor'}" stroke-width="2">${ICONS.bookmark}</svg> 818 </button> 819 </div>`; 820 821 // Comments preview 822 let commentsHtml = ''; 823 if (comments_preview && comments_preview.length > 0) { 824 const commentItems = comments_preview.map(c => renderCommentThread(c, id, 0, 2)).join(''); 825 826 const viewAll = engagement.comment_count > 2 827 ? `<div class="view-all-comments" data-post-id="${id}">${t('community.view_all_comments', 'View all {n} comments', { n: engagement.comment_count })}</div>` 828 : ''; 829 830 commentsHtml = `<div class="post-comments-section">${viewAll}${commentItems}</div>`; 831 } 832 833 // Comment input 834 const currentUser = getCurrentUser(); 835 const currentAvatarUrl = currentUser ? (getOptimizedImageUrl(currentUser.avatar_url, 'thumb') || '') : ''; 836 const commentInput = ` 837 <div class="comment-input-row"> 838 ${currentUser ? avatarRing({ avatarUrl: currentUser.avatar_url, username: currentUser.username, level: 0, xp: 0, size: 'sm', showBadge: false }) : '<div class="avatar-ring size-sm"><img class="avatar-img" src="" alt=""></div>'} 839 <textarea class="comment-input" placeholder="${t('community.add_comment', 'Add a comment...')}" data-post-id="${id}" rows="1"></textarea> 840 <button class="comment-send-btn" data-post-id="${id}">${t('community.compose.post_btn', 'Post')}</button> 841 </div>`; 842 843 return ` 844 <article class="post-card" data-type="${type}" data-post-id="${id}"> 845 ${header} 846 ${milestoneBanner} 847 ${captionHtml} 848 ${mediaHtml} 849 ${tryPromptHtml} 850 ${promoCardHtml} 851 ${pollHtml} 852 ${tagsHtml} 853 ${engagementHtml} 854 ${commentsHtml} 855 ${commentInput} 856 </article>`; 857} 858 859function formatDuration(ms) { 860 const totalSeconds = Math.floor(ms / 1000); 861 const minutes = Math.floor(totalSeconds / 60); 862 const seconds = totalSeconds % 60; 863 return `${minutes}:${String(seconds).padStart(2, '0')}`; 864} 865 866// ============================================================================ 867// SKELETON LOADER 868// ============================================================================ 869 870function skeletonPost() { 871 return ` 872 <div class="skeleton-post"> 873 <div class="skeleton-header">
874 <div class="skeleton skeleton-avatar"></div> 875 <div class="skeleton-lines" style="flex:1"><div class="skeleton skeleton-line w60"></div><div class="skeleton skeleton-line w40"></div></div> 876 </div> 877 <div class="skeleton skeleton-media"></div> 878 <div class="skeleton-actions"><div class="skeleton skeleton-btn"></div><div class="skeleton skeleton-btn"></div><div class="skeleton skeleton-btn"></div></div> 879 </div>`; 880} 881 882// ============================================================================ 883// COMPOSE BOX 884// ============================================================================ 885 886function renderCompose() { 887 const user = getCurrentUser(); 888 return ` 889 <div class="compose-box"> 890 <div class="compose-top"> 891 ${avatarRing({ avatarUrl: user?.avatar_url, username: user?.username || 'guest', level: user ? 0 : 473, xp: user ? 0 : 22000, size: 'lg' })} 892 <textarea class="compose-input" placeholder="${t('community.compose.placeholder', 'Share your latest creation, a behind-the-scenes look, or ask the community...')}" rows="2" id="composeInput" data-i18n-placeholder="community.compose.placeholder"></textarea> 893 </div> 894 <div class="compose-media-preview" id="mediaPreview"></div> 895 <div class="compose-model-chip" id="modelChip"> 896 ${icon('model', { size: 14 })} 897 <span>${t('community.compose.select_model', 'Select model')}</span> 898 ${icon('chevronDown', { size: 10 })} 899 </div> 900 <div class="compose-poll" id="pollComposer"> 901 <div class="compose-poll-header"> 902 <span class="compose-poll-title"> 903 ${icon('poll', { size: 14 })} 904 ${t('community.poll.create', 'Create a Poll')} 905 </span> 906 <button class="compose-poll-close" id="closePollBtn"> 907 ${icon('close', { size: 16 })} 908 </button> 909 </div> 910 <div class="poll-options-editor" id="pollOptionsEditor"> 911 <div class="poll-option-input-row"> 912 <div class="poll-option-number">1</div> 913 <input type="text" class="poll-option-input" placeholder="${t('community.poll.option_1_placeholder', 'Option 1 (add text or image)')}" maxlength="80"> 914 <button class="poll-option-img-btn" title="${t('community.poll.add_image', 'Add image')}">${icon('image', { size: 16 })}</button> 915 </div> 916 <div class="poll-option-input-row"> 917 <div class="poll-option-number">2</div> 918 <input type="text" class="poll-option-input" placeholder="${t('community.poll.option_2_placeholder', 'Option 2')}" maxlength="80"> 919 <button class="poll-option-img-btn" title="${t('community.poll.add_image', 'Add image')}">${icon('image', { size: 16 })}</button> 920 </div> 921 </div> 922 <button class="poll-add-option-btn" id="addOptionBtn"> 923 ${icon('plus', { size: 14 })} 924 ${t('community.poll.add_option', 'Add option')} 925 </button> 926 <div class="poll-duration-row"> 927 <span class="poll-duration-label">${t('community.poll.duration', 'Poll duration')}</span> 928 <select class="poll-duration-select" id="pollDuration"> 929 <option value="6">${t('community.poll.6_hours', '6 hours')}</option> 930 <option value="12">${t('community.poll.12_hours', '12 hours')}</option> 931 <option value="24" selected>${t('community.poll.24_hours', '24 hours')}</option> 932 <option value="48">${t('community.poll.2_days', '2 days')}</option> 933 <option value="72">${t('community.poll.3_days', '3 days')}</option> 934 <option value="168">${t('community.poll.7_days', '7 days')}</option> 935 </select> 936 </div> 937 </div> 938 <div class="compose-actions"> 939 <div class="compose-media-btns"> 940 <button class="compose-media-btn" data-media="image"> 941 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.image}</svg> 942 <span>${t('community.compose.image', 'Image')}</span> 943 </button> 944 <button class="compose-media-btn" data-media="video"> 945 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.video}</svg>
946 <span>${t('community.compose.video', 'Video')}</span> 947 </button> 948 <button class="compose-media-btn" data-media="audio"> 949 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.audio}</svg> 950 <span>${t('community.compose.audio', 'Audio')}</span> 951 </button> 952 <button class="compose-media-btn" data-media="project"> 953 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.link}</svg> 954 <span>${t('community.compose.project', 'Project')}</span> 955 </button> 956 <button class="compose-media-btn" id="pollToggleBtn" data-media="poll"> 957 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS.poll}</svg> 958 <span>${t('community.compose.poll', 'Poll')}</span> 959 </button> 960 </div> 961 <button class="btn-post" disabled id="postBtn">${t('community.compose.post_btn', 'Post')}</button> 962 </div> 963 </div>`; 964} 965 966// ============================================================================ 967// SIDEBAR 968// ============================================================================ 969
970function renderSidebar() { 971 return ` 972 <aside class="feed-sidebar"> 973 <div class="sidebar-panel"> 974 <div class="sidebar-tabs"> 975 <button class="sidebar-tab active" data-sidebar="competitions"> 976 ${icon('star', { size: 12 })} 977 ${t('community.sidebar.competitions', 'Competitions')} 978 </button> 979 <button class="sidebar-tab" data-sidebar="suggested"> 980 ${icon('userPlus', { size: 12 })} 981 ${t('community.sidebar.suggested', 'Suggested')} 982 </button> 983 <button class="sidebar-tab" data-sidebar="trending"> 984 ${icon('tag', { size: 12 })} 985 ${t('community.sidebar.trending', 'Trending')} 986 </button> 987 </div> 988 <div class="sidebar-tab-content active" data-sidebar-content="competitions" id="sidebarCompetitions"> 989 <div class="sidebar-loader"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div> 990 </div> 991 <div class="sidebar-tab-content" data-sidebar-content="suggested" id="sidebarSuggested"> 992 <div class="sidebar-loader"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div> 993 </div> 994 <div class="sidebar-tab-content" data-sidebar-content="trending" id="sidebarTrending"> 995 <div class="sidebar-loader"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div> 996 </div> 997 </div> 998 </aside>`; 999} 1000 1001function formatDeadline(dateStr) { 1002 if (!dateStr) return ''; 1003 // Already formatted (demo data like "2 days left") 1004 if (!/\d{4}-\d{2}/.test(dateStr)) return dateStr; 1005 const deadline = new Date(dateStr); 1006 if (isNaN(deadline.getTime())) return ''; 1007 const now = Date.now(); 1008 const diff = deadline.getTime() - now; 1009 if (diff <= 0) return t('community.deadline.ended', 'Ended'); 1010 const days = Math.ceil(diff / 86400000); 1011 if (days <= 1) return t('community.deadline.last_day', 'Last day!'); 1012 if (days <= 7) return `${days}${t('community.deadline.d_left', 'd left')}`; 1013 if (days <= 30) return `${Math.ceil(days / 7)}${t('community.deadline.w_left', 'w left')}`; 1014 return `${Math.ceil(days / 30)}${t('community.deadline.mo_left', 'mo left')}`; 1015} 1016 1017function formatPrize(comp) { 1018 if (comp.prize) return comp.prize; 1019 if (comp.prize_amount) return `$${Math.round(Number(comp.prize_amount) / 100).toLocaleString()}`; 1020 if (comp.scene_count) return `${comp.scene_count} ${t('community.scenes', 'scenes')}`; 1021 return ''; 1022} 1023 1024function renderCompetitionItem(comp) { 1025 const name = comp.title || comp.name || 'Untitled'; 1026 const prize = formatPrize(comp); 1027 const progress = comp.progress_pct ?? comp.progress ?? 0; 1028 const deadline = formatDeadline(comp.deadline); 1029 const category = comp.category || ''; 1030 const isEnded = deadline === t('community.deadline.ended', 'Ended'); 1031 const fundedCents = comp.funding_current || 0; 1032 const goalCents = comp.prize_amount || comp.funding_goal || 0; 1033 const fundedAmt = Math.round(fundedCents / 100); 1034 const fundedGoal = Math.round(goalCents / 100); 1035 const projectId = comp.id || ''; 1036 const thumbUrl = comp.thumbnail_url || ''; 1037 const videoUrl = comp.video_url || ''; 1038 const hasVideo = !!videoUrl; 1039 1040 // Poster image: use thumbnail, or extract video poster frame 1041 let posterSrc = ''; 1042 if (thumbUrl) { 1043 posterSrc = getOptimizedImageUrl(thumbUrl, 'gallery') || thumbUrl; 1044 } else if (hasVideo) { 1045 posterSrc = getOptimizedVideoPoster(videoUrl, 'gallery') || ''; 1046 } 1047 const hasPoster = !!posterSrc; 1048 1049 // Funding line 1050 let fundingHtml = ''; 1051 const dollarSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'; 1052 if (fundedAmt > 0 && fundedGoal > 0) { 1053 const fundPct = Math.min(100, Math.round(fundedAmt / fundedGoal * 100)); 1054 fundingHtml = `<div class="comp-funded">${dollarSvg} $${fundedAmt.toLocaleString()} ${t('community.of', 'of')} $${fundedGoal.toLocaleString()} (${fundPct}%)</div>`; 1055 } else if (fundedAmt > 0) { 1056 fundingHtml = `<div class="comp-funded">${dollarSvg}
1056 $${fundedAmt.toLocaleString()} ${t('community.funded', 'funded')}</div>`; 1057 } else if (fundedGoal > 0) { 1058 fundingHtml = `<div class="comp-funded comp-funded-goal">${dollarSvg} ${t('community.goal', 'Goal')}: $${fundedGoal.toLocaleString()}</div>`; 1059 } 1060 1061 return ` 1062 <a href="/project/${projectId}" class="sidebar-item comp-link${isEnded ? ' comp-ended' : ''}" data-project-id="${escapeHtml(String(projectId))}"> 1063 <div class="comp-thumb-wrap"> 1064 ${hasPoster ? `<img class="comp-thumb-img" src="${posterSrc}" alt="" loading="lazy">` : ''} 1065 ${!hasPoster ? `<div class="comp-thumb-placeholder"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/></svg></div>` : ''} 1066 ${hasVideo ? `<video class="comp-thumb-video" data-src="${escapeHtml(videoUrl)}#t=0.5" muted playsinline preload="none" loop></video>` : ''} 1067 ${hasVideo ? `<div class="comp-play-btn">${icon('play', { size: 18, fill: 'white', stroke: 'none', strokeWidth: 0 })}</div>` : ''} 1068 ${hasVideo ? `<button class="comp-mute-btn muted" title="Unmute"> 1069 <svg class="icon-muted" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> 1070 <svg class="icon-unmuted" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> 1071 </button>` : ''} 1072 ${hasVideo ? `<button class="comp-expand-btn" data-video-src="${escapeHtml(videoUrl)}" title="Full screen"> 1073 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> 1074 </button>` : ''} 1075 </div> 1076 <div class="comp-item-body"> 1077 <div class="comp-item-name">${escapeHtml(name)}</div> 1078 <div class="comp-item-meta"> 1079 <span>${escapeHtml(category)}</span> 1080 <span>${comp.scene_count || 0} ${t('community.scenes', 'scenes')}</span> 1081 <span>${comp.entry_count || 0} ${t('community.entries', 'entries')}</span> 1082 ${deadline ? `<span class="${isEnded ? '' : 'urgent'}">${escapeHtml(deadline)}</span>` : ''} 1083 </div> 1084 ${fundingHtml} 1085 <div class="comp-item-bar"><div class="comp-item-bar-fill" style="width:${progress}%"></div></div> 1086 ${!isEnded ? `<button class="comp-enter-btn" data-comp-id="${escapeHtml(String(projectId))}"> 1087 ${icon('bolt', { size: 10, strokeWidth: 2.5 })} 1088 ${t('community.enter_now', 'Enter Now')} 1089 </button>` : ''} 1090 </div> 1091 </a>`; 1092} 1093
1094function renderSuggestedItem(user) { 1095 return ` 1096 <div class="sidebar-item suggest-item"> 1097 <a class="suggest-profile-link" href="/profile/${encodeURIComponent(user.username)}" data-username="${escapeHtml(user.username)}"> 1098 ${avatarRing({ avatarUrl: user.avatar_url, username: user.username, level: user.level || 0, xp: user.xp || 0, size: 'md' })} 1099 <div class="suggest-info"> 1100 <div class="suggest-name">${escapeHtml(user.display_name || user.username)}</div> 1101 <div class="suggest-handle">@${escapeHtml(user.username)} · â ${formatCount(user.follower_count || 0)}</div> 1102 ${user.reason ? `<div class="suggest-reason">${escapeHtml(user.reason)}</div>` : ''} 1103 </div> 1104 </a> 1105 <button class="btn-follow" data-user-id="${user.id}">${t('community.follow', 'Follow')}</button> 1106 </div>`; 1107} 1108 1109function renderTrendingItem(tag, index) { 1110 const rankClass = index < 3 ? 'top' : 'normal'; 1111 const sparkColor = tag.velocity_change > 0 ? '#22C55E' : (index < 3 ? '#DC2626' : '#666'); 1112 return ` 1113 <div class="sidebar-item trend-item"> 1114 <div class="trend-rank ${rankClass}">${index + 1}</div> 1115 <div class="trend-info"> 1116 <div class="trend-name">#${escapeHtml(tag.tag)}</div> 1117 <div class="trend-count">${formatCount(tag.post_count)} ${t('community.posts', 'posts')}${tag.today_count ? ` · +${tag.today_count} ${t('community.today', 'today')}` : ''}</div> 1118 </div> 1119 <div class="trend-spark"><svg viewBox="0 0 48 20" fill="none"><polyline points="2,16 8,14 14,10 20,12 26,6 32,8 38,3 44,5" stroke="${sparkColor}" stroke-width="${index < 3 ? '2' : '1.5'}" stroke-linecap="round" fill="none"/></svg></div> 1120 </div>`; 1121} 1122 1123// ============================================================================ 1124// PAGE RENDER 1125// ============================================================================ 1126 1127export async function render(params) { 1128 // Load community CSS before returning HTML to prevent FOUC 1129 if (!document.querySelector('link[href*="community.css"]')) { 1130 await new Promise((resolve) => { 1131 const link = document.createElement('link'); 1132 link.rel = 'stylesheet'; 1133 link.href = '/css/community.css?v=20260224portraitfix2'; 1134 link.onload = resolve; 1135 link.onerror = resolve; 1136 document.head.appendChild(link); 1137 }); 1138 } 1139 1140 const authed = isAuthenticated(); 1141 const defaultTab = 'discover'; 1142 state.activeTab = defaultTab; 1143 state.singlePostId = params?.postId || null; 1144 1145 return ` 1146 <div class="community-page"> 1147 <div class="feed-layout"> 1148 <main class="feed-main"> 1149 ${state.singlePostId ? ` 1150 <a href="/community" class="single-post-back" data-link> 1151 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg> 1152 ${t('community.back', 'Back to Community')} 1153 </a>` : ` 1154 <div class="feed-tabs"> 1155 ${authed ? `<button class="feed-tab${defaultTab === 'following' ? ' active' : ''}" data-tab="following">${t('community.tabs.following', 'Following')}</button>` : ''} 1156 <button class="feed-tab${defaultTab === 'discover' ? ' active' : ''}" data-tab="discover">${t('community.tabs.discover', 'Discover')}</button> 1157 <button class="feed-tab" data-tab="trending">${t('community.tabs.trending', 'Trending')}</button> 1158 </div> 1159 ${renderCompose()}`} 1160 <div id="feedContainer"> 1161 ${skeletonPost()} 1162 </div> 1163 ${!state.singlePostId ? `<div id="loadingSkeleton" style="display:none"> 1164 ${skeletonPost()} 1165 </div>` : ''} 1166 </main> 1167 ${renderSidebar()} 1168 </div> 1169 </div> 1170 ${!state.singlePostId ? `<button class="fab-compose" id="fabCompose"> 1171 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> 1172 </button>` : ''}`; 1173} 1174 1175// ============================================================================ 1176// PAGE MOUNT 1177// ============================================================================ 1178 1179export async function onMount(container, params) { 1180 // Remove hero poster preloads (homepage-only, wastes bandwidth on community page) 1181 document.querySelectorAll('link[rel="preload"][href*="hero-poster"]').forEach(el => el.remove()); 1182 1183 state.container = container; 1184 1185 if (state.singlePostId) { 1186 // Single post view â fetch and display one post 1187 await loadSinglePost(state.singlePostId); 1188 } else { 1189 // Normal feed view 1190 await loadFeed(state.activeTab, false); 1191 initFeedTabs(container);
1192 initComposeBox(container); 1193 initInfiniteScroll(container); 1194 initFAB(container); 1195 1196 // Feed auto-poll DISABLED â re-fetching the full feed every 30s caused 1197 // the page to feel slower the longer it stayed open (each poll re-renders 1198 // 20 posts with media + comments). Users can pull-to-refresh / reload 1199 // to see new posts. 1200 } 1201 1202 // Common handlers for both views 1203 initEngagementHandlers(container); 1204 initSidebarTabs(container); 1205 initMobileSwipe(container); 1206 initCarouselSwipe(container); 1207 initShowMore(container); 1208 1209 // Lazy-load videos only when near viewport 1210 initLazyVideos(container); 1211 1212 // Back link for single post 1213 const backLink = container.querySelector('.single-post-back'); 1214 if (backLink) { 1215 backLink.addEventListener('click', (e) => { 1216 e.preventDefault(); 1217 window.ARTURUM?.navigate('/community'); 1218 }); 1219 } 1220 1221 // Load sidebar data 1222 loadSidebarData(); 1223} 1224 1225// ============================================================================ 1226// PAGE UNMOUNT 1227// ============================================================================ 1228 1229export function onUnmount(container) { 1230 if (state.pollInterval) { 1231 clearInterval(state.pollInterval); 1232 state.pollInterval = null; 1233 } 1234 if (state.scrollObserver) { 1235 state.scrollObserver.disconnect(); 1236 state.scrollObserver = null; 1237 } 1238 if (state.videoObserver) { 1239 state.videoObserver.disconnect(); 1240 state.videoObserver = null; 1241 } 1242 state.posts = []; 1243 state.nextCursor = null; 1244 state.container = null; 1245 state.selectedMedia = []; 1246 state.selectedProjectId = null; 1247 state.selectedProjectTitle = null; 1248 1249 // Close project picker if open 1250 _closeProjectPicker(); 1251 1252 // Close media picker if open 1253 if (mediaPicker) mediaPicker.close(); 1254} 1255 1256// Pause background work when this page enters DOM cache. 1257// Keep state.posts / state.container alive so resume is instant. 1258export function onCachePause(container) { 1259 if (state.pollInterval) { 1260 clearInterval(state.pollInterval); 1261 state.pollInterval = null; 1262 } 1263 if (state.scrollObserver) { 1264 state.scrollObserver.disconnect(); 1265 state.scrollObserver = null; 1266 } 1267 if (state.videoObserver) { 1268 state.videoObserver.disconnect(); 1269 state.videoObserver = null; 1270 } 1271} 1272 1273// Re-arm observers on the live DOM that just got restored. Auto-poll is 1274// disabled (see onMount comment), so resume just re-attaches IntersectionObservers. 1275export function onCacheResume(container) { 1276 state.container = container; 1277 if (!state.singlePostId) { 1278 initInfiniteScroll(container); 1279 } 1280 initLazyVideos(container); 1281} 1282 1283// ============================================================================ 1284// SINGLE POST VIEW 1285// ============================================================================ 1286 1287async function loadSinglePost(postId) { 1288 const feedContainer = state.container?.querySelector('#feedContainer'); 1289 if (!feedContainer) return; 1290 1291 try { 1292 const data = await apiClient.get(`/api/posts/${postId}`, { requiresAuth: isAuthenticated(), skipErrorToast: true }); 1293 if (data?.success && data.data?.post) { 1294 const raw = data.data.post; 1295 // Normalize single-post API shape to match feed API shape 1296 const post = { 1297 id: raw.id, 1298 type: raw.type || 'showcase', 1299 caption: raw.caption || '', 1300 created_at: raw.created_at, 1301 generation_meta: raw.generation_meta || null, 1302 linked_project: raw.linked_project || null, 1303 poll: raw.poll_data || null, 1304 tags: raw.tags || [], 1305 author: { 1306 id: raw.author?.id, 1307 username: raw.author?.username || 'unknown', 1308 display_name: raw.author?.display_name || null, 1309 avatar_url: raw.author?.avatar_url || null, 1310 level: raw.author?.current_level || raw.author?.level || 0, 1311 xp: raw.author?.total_xp || raw.author?.xp || 0, 1312 is_verified: !!raw.author?.is_verified, 1313 streak_days: raw.author?.current_streak || raw.author?.streak_days || 0, 1314 }, 1315 media: (raw.media || []).map(m => ({ 1316 ...m, 1317 type: m.type || m.media_type || 'image', 1318 })), 1319 engagement: { 1320 like_count: raw.like_count || 0, 1321 comment_count: raw.comment_count || 0, 1322 remix_count: raw.remix_count || 0, 1323 share_count: raw.share_count || 0, 1324 is_liked: raw.engagement?.liked || raw.engagement?.is_liked || false, 1325 is_bookmarked: raw.engagement?.bookmarked || raw.engagement?.is_bookmarked || false, 1326 }, 1327 comments_preview: (raw.comments || []).slice(0, 3).map(c => ({ 1328 id: c.id, 1329 body: c.body, 1330 created_at: c.created_at, 1331 like_count: c.like_count || 0, 1332 author: { 1333 username: c.username || c.author?.username || 'unknown', 1334 avatar_url: c.avatar_url || c.author?.avatar_url || null, 1335 level: c.author_level || c.author?.level || 0, 1336 }, 1337 })), 1338 }; 1339 state.posts = [post]; 1340 feedContainer.innerHTML = renderPostCard(post); 1341 initShowMore(state.container); 1342 } else { 1343 feedContainer.innerHTML = `<div style="text-align:center;padding:48px 16px;color:#888;"> 1344 <p style="font-size:18px;margin:0 0 8px;">Post not found</p> 1345 <p style="margin:0;">It may have been deleted or is no longer available.</p> 1346 </div>`; 1347 } 1348 } catch (err) { 1349 feedContainer.innerHTML = `<div style="text-align:center;padding:48px 16px;color:#888;"> 1350 <p style="font-size:18px;margin:0 0 8px;">Could not load post</p> 1351 <p style="margin:0;">Please try again later.</p> 1352 </div>`; 1353 } 1354} 1355 1356// ============================================================================ 1357// FEED LOADING 1358// ============================================================================ 1359 1360async function loadFeed(tab, append = false, silent = false) { 1361 if (state.isLoading) return; 1362 state.isLoading = true; 1363 1364 const feedContainer = state.container?.querySelector('#feedContainer'); 1365 const loadingSkeleton = state.container?.querySelector('#loadingSkeleton'); 1366 if (!feedContainer) { state.isLoading = false; return; } 1367 1368 if (!append && !silent) { 1369 feedContainer.innerHTML = skeletonPost() + skeletonPost() + skeletonPost(); 1370 } 1371 if (append && loadingSkeleton) { 1372 loadingSkeleton.style.display = 'block'; 1373 loadingSkeleton.style.visibility = 'visible'; 1374 loadingSkeleton.style.height = ''; 1375 loadingSkeleton.style.overflow = ''; 1376 } 1377 1378 try { 1379 let endpoint; 1380 const cursor = append ? state.nextCursor : null; 1381 const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''; 1382 1383 switch (tab) { 1384 case 'following': 1385 endpoint = `/api/feed/following?limit=${POSTS_PER_PAGE}${cursorParam}`; 1386 break; 1387 case 'trending': 1388 endpoint = `/api/feed/trending?limit=${POSTS_PER_PAGE}${cursorParam}`; 1389 break; 1390 case 'discover': 1391 default: 1392 endpoint = `/api/feed/discover?limit=${POSTS_PER_PAGE}${cursorParam}`; 1393 break; 1394 } 1395 1396 const data = await apiClient.get(endpoint, { requiresAuth: isAuthenticated(), skipErrorToast: true }); 1397 1398 if (!data || !data.success) { 1399 if (!append && !silent) { 1400 // Fall back to demo data 1401 state.posts = DEMO_POSTS; 1402 feedContainer.innerHTML = state.posts.map(p => safeRenderPostCard(p)).join(''); 1403 initShowMore(state.container); 1404 } 1405 return; 1406 } 1407 1408 const newPosts = data.data.posts || []; 1409 state.nextCursor = data.data.next_cursor || null; 1410 1411 if (append) { 1412 state.posts = [...state.posts, ...newPosts]; 1413 } else if (silent && newPosts.length > 0) { 1414 // Check for new posts at the top 1415 const existingIds = new Set(state.posts.map(p => p.id)); 1416 const trulyNew = newPosts.filter(p => !existingIds.has(p.id)); 1417 if (trulyNew.length > 0) { 1418 state.posts = [...trulyNew, ...state.posts]; 1419 const newHtml = trulyNew.map(p => safeRenderPostCard(p)).join(''); 1420 feedContainer.insertAdjacentHTML('afterbegin', newHtml); 1421 initShowMore(state.container); 1422 observeNewLazyVideos(feedContainer); 1423 } 1424 return; 1425 } else { 1426 state.posts = newPosts; 1427 } 1428 1429 if (!silent) { 1430 if (append) { 1431 const newHtml = newPosts.map(p => safeRenderPostCard(p)).join(''); 1432 feedContainer.insertAdjacentHTML('beforeend', newHtml); 1433 } else { 1434 if (state.posts.length === 0) { 1435 if (tab === 'following') { 1436 feedContainer.innerHTML = `<div class="feed-empty-state"> 1437 <p style="color:#aaa;text-align:center;padding:2rem 1rem;">You haven't followed anyone yet. Explore the <strong>Discover</strong> tab to find creators!</p> 1438 </div>`; 1439 } else if (tab === 'trending') { 1440 feedContainer.innerHTML = `<div class="feed-empty-state"> 1441 <p style="color:#aaa;text-align:center;padding:2rem 1rem;">No trending posts yet. Be the first to create engaging content!</p> 1442 </div>`; 1443 } else { 1444 // Use demo data only for discover when no real posts exist 1445 state.posts = DEMO_POSTS; 1446 feedContainer.innerHTML = state.posts.map(p => safeRenderPostCard(p)).join(''); 1447 } 1448 } else { 1449 feedContainer.innerHTML = state.posts.map(p => safeRenderPostCard(p)).join(''); 1450 } 1451 } 1452 initShowMore(state.container); 1453 observeNewLazyVideos(feedContainer); 1454 } 1455 } catch (err) { 1456 console.error('Failed to load feed, using demo data:', err); 1457 if (!append && !silent) { 1458 // Fall back to demo data so the page looks populated 1459 state.posts = DEMO_POSTS; 1460 feedContainer.innerHTML = state.posts.map(p => safeRenderPostCard(p)).join(''); 1461 initShowMore(state.container); 1462 observeNewLazyVideos(feedContainer); 1463 } 1464 } finally { 1465 state.isLoading = false;
1466 if (loadingSkeleton) { 1467 if (state.nextCursor) { 1468 // Keep sentinel in layout so IntersectionObserver can fire for next page 1469 loadingSkeleton.style.display = 'block'; 1470 loadingSkeleton.style.visibility = 'hidden'; 1471 loadingSkeleton.style.height = '1px'; 1472 loadingSkeleton.style.overflow = 'hidden'; 1473 } else { 1474 // No more pages â fully hide sentinel 1475 loadingSkeleton.style.display = 'none'; 1476 } 1477 } 1478 } 1479} 1480 1481// ============================================================================ 1482// SIDEBAR DATA 1483// ============================================================================ 1484 1485async function loadSidebarData() { 1486 let compData = null, suggestData = null, trendData = null; 1487 1488 try { 1489 const [competitions, suggested, trending] = await Promise.allSettled([ 1490 apiClient.get('/api/community/competitions', { requiresAuth: false, skipErrorToast: true }), 1491 apiClient.get('/api/community/suggested', { requiresAuth: isAuthenticated(), skipErrorToast: true }), 1492 apiClient.get('/api/community/trending-tags', { requiresAuth: false, skipErrorToast: true }), 1493 ]); 1494 1495 if (competitions.status === 'fulfilled' && competitions.value?.data?.competitions?.length) { 1496 compData = competitions.value.data.competitions; 1497 } 1498 if (suggested.status === 'fulfilled' && (suggested.value?.data?.suggested?.length || suggested.value?.data?.users?.length)) { 1499 suggestData = suggested.value.data.suggested || suggested.value.data.users; 1500 } 1501 if (trending.status === 'fulfilled' && trending.value?.data?.tags?.length) { 1502 trendData = trending.value.data.tags; 1503 } 1504 } catch (err) { 1505 console.log('Sidebar API failed, using demo data:', err); 1506 } 1507 1508 // Fall back to demo data 1509 if (!compData) compData = DEMO_COMPETITIONS; 1510 if (!suggestData) suggestData = DEMO_SUGGESTED; 1511 if (!trendData) trendData = DEMO_TRENDING; 1512 1513 // Competitions 1514 const compContainer = state.container?.querySelector('#sidebarCompetitions'); 1515 if (compContainer) { 1516 compContainer.innerHTML = compData.map(c => renderCompetitionItem(c)).join(''); 1517 } 1518 1519 // Suggested 1520 const suggestContainer = state.container?.querySelector('#sidebarSuggested'); 1521 if (suggestContainer) { 1522 suggestContainer.innerHTML = suggestData.map(u => renderSuggestedItem(u)).join(''); 1523 } 1524 1525 // Trending 1526 const trendContainer = state.container?.querySelector('#sidebarTrending'); 1527 if (trendContainer) { 1528 trendContainer.innerHTML = trendData.map((t, i) => renderTrendingItem(t, i)).join(''); 1529 } 1530 1531 // Observe any new lazy videos in sidebar (competition thumbnails) 1532 if (state.container) observeNewLazyVideos(state.container); 1533} 1534 1535// ============================================================================ 1536// EVENT HANDLERS â Feed Tabs 1537// ============================================================================ 1538 1539function initFeedTabs(container) { 1540 container.addEventListener('click', (e) => { 1541 const tab = e.target.closest('.feed-tab'); 1542 if (!tab) return; 1543 1544 const tabName = tab.dataset.tab; 1545 if (tabName === state.activeTab) return; 1546 1547 // Check auth for following tab 1548 if (tabName === 'following' && !isAuthenticated()) { 1549 showToast('Sign in to see posts from people you follow', 'info'); 1550 return; 1551 } 1552 1553 // Update UI 1554 container.querySelectorAll('.feed-tab').forEach(t => t.classList.remove('active')); 1555 tab.classList.add('active'); 1556 1557 state.activeTab = tabName; 1558 state.nextCursor = null; 1559 loadFeed(tabName, false); 1560 }); 1561} 1562 1563// ============================================================================ 1564// EVENT HANDLERS â Compose Box 1565// ============================================================================ 1566 1567function initComposeBox(container) { 1568 const composeInput = container.querySelector('#composeInput'); 1569 const postBtn = container.querySelector('#postBtn'); 1570 if (!composeInput || !postBtn) return; 1571 1572 // Auto-resize textarea 1573 composeInput.addEventListener('input', () => { 1574 composeInput.style.height = 'auto'; 1575 composeInput.style.height = composeInput.scrollHeight + 'px'; 1576 updatePostButton(container); 1577 }); 1578 1579 // Post button 1580 postBtn.addEventListener('click', () => submitPost(container)); 1581 1582 // Poll toggle 1583 const pollToggleBtn = container.querySelector('#pollToggleBtn'); 1584 if (pollToggleBtn) { 1585 pollToggleBtn.addEventListener('click', () => togglePollComposer(container)); 1586 } 1587 const closePollBtn = container.querySelector('#closePollBtn'); 1588 if (closePollBtn) { 1589 closePollBtn.addEventListener('click', () => togglePollComposer(container)); 1590 } 1591 1592 // Add poll option 1593 const addOptionBtn = container.querySelector('#addOptionBtn'); 1594 if (addOptionBtn) { 1595 addOptionBtn.addEventListener('click', () => addPollOption(container)); 1596 } 1597 1598 // Poll option inputs + image buttons (event delegation) 1599 const pollEditor = container.querySelector('#pollOptionsEditor'); 1600 if (pollEditor) { 1601 pollEditor.addEventListener('input', () => { 1602 updatePostButton(container); 1603 }); 1604 pollEditor.addEventListener('click', (e) => { 1605 const imgBtn = e.target.closest('.poll-option-img-btn'); 1606 if (!imgBtn) return; 1607 e.preventDefault(); 1608 if (!isAuthenticated()) { 1609 showToast('Sign in to add media', 'info'); 1610 return; 1611 }
1612 const row = imgBtn.closest('.poll-option-input-row'); 1613 const rows = Array.from(pollEditor.querySelectorAll('.poll-option-input-row')); 1614 const optionIdx = rows.indexOf(row); 1615 if (optionIdx < 0) return; 1616 1617 // Show media type chooser 1618 _showPollMediaChooser(imgBtn, row, optionIdx, container); 1619 }); 1620 } 1621 1622 // Media buttons (Image, Video, Audio) 1623 container.querySelectorAll('.compose-media-btn[data-media]').forEach(btn => { 1624 const mediaType = btn.dataset.media; 1625 if (mediaType === 'poll') return; // handled above 1626 1627 if (mediaType === 'project') { 1628 btn.addEventListener('click', () => openProjectSelector(container)); 1629 return; 1630 } 1631 1632 // image â images, video â videos, audio â audio 1633 const pickerType = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audio'; 1634 btn.addEventListener('click', () => { 1635 if (!isAuthenticated()) { 1636 showToast('Sign in to add media', 'info'); 1637 return; 1638 } 1639 if (!mediaPicker) mediaPicker = new MediaPickerModal(); 1640 mediaPicker.open({ 1641 type: pickerType, 1642 maxSelect: 5, 1643 onConfirm: (items) => { 1644 state.selectedMedia = [...state.selectedMedia, ...items].slice(0, 10); 1645 renderMediaPreview(container); 1646 updatePostButton(container); 1647 }, 1648 }); 1649 }); 1650 }); 1651} 1652 1653function updatePostButton(container) { 1654 const composeInput = container.querySelector('#composeInput'); 1655 const postBtn = container.querySelector('#postBtn'); 1656 if (!composeInput || !postBtn) return; 1657 1658 const hasText = composeInput.value.trim().length > 0; 1659 const hasMedia = state.selectedMedia.length > 0; 1660 const hasProject = !!state.selectedProjectId; 1661 if (state.pollComposerActive) { 1662 const inputs = container.querySelectorAll('#pollOptionsEditor .poll-option-input'); 1663 const filled = Array.from(inputs).filter(i => i.value.trim().length > 0).length; 1664 postBtn.disabled = !(hasText && filled >= 2); 1665 } else { 1666 postBtn.disabled = !(hasText || hasMedia || hasProject); 1667 } 1668} 1669 1670// ============================================================================ 1671// COMPOSE â Media Preview & Project Selector 1672// ============================================================================ 1673 1674function renderMediaPreview(container) { 1675 const preview = container.querySelector('#mediaPreview'); 1676 if (!preview) return; 1677 1678 if (state.selectedMedia.length === 0 && !state.selectedProjectId) { 1679 preview.innerHTML = ''; 1680 preview.classList.remove('has-media'); 1681 return; 1682 } 1683 1684 preview.classList.add('has-media'); 1685 let html = ''; 1686 1687 // Media items 1688 html += state.selectedMedia.map((item, idx) => { 1689 if (item.media_type === 'audio') { 1690 const name = item._name || 'Audio'; 1691 if (item.thumbnail_url) { 1692 const artSrc = getOptimizedImageUrl(item.thumbnail_url, 'thumb') || item.thumbnail_url; 1693 return `<div class="compose-preview-item"> 1694 <img src="${artSrc}" alt=""> 1695 <span class="compose-preview-badge">âª</span> 1696 <button class="compose-preview-remove" data-remove-media="${idx}">â</button> 1697 </div>`; 1698 } 1699 return `<div class="compose-preview-item compose-preview-audio"> 1700 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#EA2831" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> 1701 <span class="compose-preview-name">${escapeHtml(name)}</span> 1702 <button class="compose-preview-remove" data-remove-media="${idx}">â</button> 1703 </div>`; 1704 } 1705 if (item.media_type === 'video') { 1706 return `<div class="compose-preview-item"> 1707 <video src="${escapeHtml(item.url)}#t=0.5" preload="metadata" muted playsinline style="width:100%;height:100%;object-fit:cover;"></video> 1708 <span class="compose-preview-badge">â¶</span> 1709 <button class="compose-preview-remove" data-remove-media="${idx}">â</button> 1710 </div>`; 1711 } 1712 const src = getOptimizedImageUrl(item.thumbnail_url || item.url, 'gallery') || item.thumbnail_url || item.url; 1713 return `<div class="compose-preview-item"> 1714 <img src="${src || ''}" alt=""> 1715 <button class="compose-preview-remove" data-remove-media="${idx}">â</button> 1716 </div>`; 1717 }).join(''); 1718 1719 // Project chip 1720 if (state.selectedProjectId) { 1721 html += `<div class="compose-preview-item compose-preview-project"> 1722 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#EA2831" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
1723 <span class="compose-preview-name">${escapeHtml(state.selectedProjectTitle || 'Project')}</span> 1724 <button class="compose-preview-remove" data-remove-project>â</button> 1725 </div>`; 1726 } 1727 1728 preview.innerHTML = html; 1729 1730 // Remove handlers 1731 preview.querySelectorAll('[data-remove-media]').forEach(btn => { 1732 btn.addEventListener('click', (e) => { 1733 e.stopPropagation(); 1734 const idx = parseInt(btn.dataset.removeMedia, 10); 1735 state.selectedMedia.splice(idx, 1); 1736 renderMediaPreview(container); 1737 updatePostButton(container); 1738 }); 1739 }); 1740 preview.querySelectorAll('[data-remove-project]').forEach(btn => { 1741 btn.addEventListener('click', (e) => { 1742 e.stopPropagation(); 1743 state.selectedProjectId = null; 1744 state.selectedProjectTitle = null; 1745 renderMediaPreview(container); 1746 updatePostButton(container); 1747 }); 1748 }); 1749} 1750 1751// ============================================================================ 1752// PROJECT PICKER MODAL 1753// ============================================================================ 1754 1755let _projectPickerOverlay = null; 1756let _projectsCache = null; // cached project list for instant re-open 1757 1758function openProjectSelector(container) { 1759 if (!isAuthenticated()) { 1760 showToast('Sign in to link a project', 'info'); 1761 return; 1762 } 1763 1764 // If already selected, deselect 1765 if (state.selectedProjectId) { 1766 state.selectedProjectId = null; 1767 state.selectedProjectTitle = null; 1768 renderMediaPreview(container); 1769 updatePostButton(container); 1770 return; 1771 } 1772 1773 _showProjectPicker(container); 1774} 1775 1776function _showProjectPicker(container) { 1777 // Reuse existing overlay â render from cache if available 1778 if (_projectPickerOverlay) { 1779 _projectPickerOverlay.style.display = 'flex'; 1780 document.body.style.overflow = 'hidden'; 1781 // Reset search input 1782 const searchInput = _projectPickerOverlay.querySelector('.pp-search-input'); 1783 if (searchInput) searchInput.value = ''; 1784 if (_projectsCache) { 1785 _renderProjectGrid(_projectsCache); 1786 } else { 1787 _loadProjects(); 1788 } 1789 return; 1790 } 1791 1792 const el = document.createElement('div'); 1793 el.className = 'creation-modal-overlay'; 1794 el.style.display = 'flex'; 1795 el.innerHTML = ` 1796 <div class="creation-modal-container mx-auto" style="max-width:640px;"> 1797 <div class="creation-modal-header"> 1798 <button class="creation-close-btn" data-action="pp-close">â</button> 1799 <h2 style="font-size:20px;font-weight:700;color:#fff;margin-bottom:4px;" data-i18n="community.link_project.title">Link a Project</h2> 1800 <p style="font-size:13px;color:#888;margin:0;">Select one of your published projects to share</p> 1801 </div> 1802 <div class="creation-modal-body" style="padding:16px 24px;"> 1803 <div class="pp-search" style="margin-bottom:14px;"> 1804 <input type="text" class="pp-search-input" placeholder="Search your projects..." style="width:100%;padding:10px 14px;border-radius:10px;border:1px solid #2a2a2a;background:#141414;color:#fff;font-size:13px;font-family:inherit;outline:none;transition:border-color .2s;"> 1805 </div> 1806 <div class="pp-grid" style="display:flex;flex-direction:column;gap:8px;max-height:400px;overflow-y:auto;padding-right:4px;"></div> 1807 <div class="pp-empty" style="display:none;text-align:center;padding:40px 16px;color:#666;"> 1808 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#444" stroke-width="1.5" style="margin:0 auto 12px;"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> 1809 <p style="font-size:13px;">No projects found</p> 1810 <p style="font-size:12px;color:#555;margin-top:4px;">Create a project first on the homepage</p> 1811 </div> 1812 <div class="pp-loading" style="text-align:center;padding:40px 16px;color:#888;"> 1813 <div style="display:inline-block;width:24px;height:24px;border:2px solid #333;border-top-color:#EA2831;border-radius:50%;animation:spin .6s linear infinite;"></div> 1814 <p style="font-size:13px;margin-top:10px;">Loading projects...</p> 1815 </div> 1816 </div> 1817 </div> 1818 `; 1819 1820 document.body.appendChild(el); 1821 document.body.style.overflow = 'hidden'; 1822 _projectPickerOverlay = el; 1823 1824 // Close events 1825 el.addEventListener('click', (e) => { 1826 if (e.target === el || e.target.closest('[data-action="pp-close"]')) { 1827 _closeProjectPicker(); 1828 } 1829 }); 1830 1831 // Search input 1832 let searchTimer = null; 1833 el.querySelector('.pp-search-input').addEventListener('input', (e) => { 1834 clearTimeout(searchTimer); 1835 searchTimer = setTimeout(() => _loadProjects(e.target.value.trim()), 250); 1836 }); 1837 el.querySelector('.pp-search-input').addEventListener('focus', (e) => { 1838 e.target.style.borderColor = '#DC2626'; 1839 }); 1840 el.querySelector('.pp-search-input').addEventListener('blur', (e) => { 1841 e.target.style.borderColor = '#2a2a2a'; 1842 }); 1843 1844 // Project card click (delegate) 1845 el.querySelector('.pp-grid').addEventListener('click', (e) => { 1846 const card = e.target.closest('.pp-project-card'); 1847 if (!card) return; 1848 state.selectedProjectId = parseInt(card.dataset.pid, 10); 1849 state.selectedProjectTitle = card.dataset.ptitle; 1850 renderMediaPreview(container); 1851 updatePostButton(container); 1852 _closeProjectPicker(); 1853 }); 1854 1855 // Escape key 1856 const onKey = (e) => { 1857 if (e.key === 'Escape' && _projectPickerOverlay?.style.display === 'flex') { 1858 _closeProjectPicker(); 1859 } 1860 }; 1861 document.addEventListener('keydown', onKey); 1862 el._onKey = onKey; 1863 1864 _loadProjects(); 1865} 1866 1867function _closeProjectPicker() { 1868 if (!_projectPickerOverlay) return; 1869 _projectPickerOverlay.style.display = 'none'; 1870 document.body.style.overflow = ''; 1871} 1872 1873function _renderProjectGrid(projects) { 1874 const el = _projectPickerOverlay; 1875 if (!el) return; 1876 const grid = el.querySelector('.pp-grid'); 1877 const emptyEl = el.querySelector('.pp-empty'); 1878 el.querySelector('.pp-loading').style.display = 'none'; 1879 1880 if (projects.length === 0) { 1881 grid.innerHTML = ''; 1882 emptyEl.style.display = 'block'; 1883 return; 1884 } 1885 1886 emptyEl.style.display = 'none'; 1887 grid.innerHTML = projects.map(p => { 1888 const thumbUrl = p.thumbnail_url ? (getOptimizedImageUrl(p.thumbnail_url, 'gallery') || p.thumbnail_url) : ''; 1889 const hasVideo = !thumbUrl && p.video_url; 1890 const category = p.category || 'Film'; 1891 const scenes = p.scene_count || 0; 1892 const fundPct = p.funding_goal > 0 ? Math.min(Math.round((p.current_funding || 0) / p.funding_goal * 100), 100) : 0; 1893 return `<button class="pp-project-card" data-pid="${p.id}" data-ptitle="${escapeHtml(p.title)}"> 1894 <div class="pp-card-thumb"> 1895 ${thumbUrl ? `<img src="${thumbUrl}" alt="" loading="lazy">` 1896 : hasVideo ? `<video data-src="${escapeHtml(p.video_url)}#t=0.5" preload="none" muted playsinline style="width:100%;height:100%;object-fit:cover;pointer-events:none;" class="lazy-video"></video>` 1897 : `<div class="pp-card-placeholder"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#555" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></div>`} 1898 </div> 1899 <div class="pp-card-body"> 1900 <div class="pp-card-title">${escapeHtml(p.title)}</div> 1901 <div class="pp-card-meta">
1902 <span class="pp-card-badge">${escapeHtml(category)}</span> 1903 <span>${scenes} scene${scenes !== 1 ? 's' : ''}</span> 1904 ${fundPct > 0 ? `<span>· ${fundPct}% funded</span>` : ''} 1905 </div> 1906 ${p.description ? `<div class="pp-card-desc">${escapeHtml(p.description.substring(0, 100))}${p.description.length > 100 ? '...' : ''}</div>` : ''} 1907 </div> 1908 <div class="pp-card-select"> 1909 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg> 1910 </div> 1911 </button>`; 1912 }).join(''); 1913} 1914 1915async function _loadProjects(search = '') { 1916 const el = _projectPickerOverlay; 1917 if (!el) return; 1918 const grid = el.querySelector('.pp-grid'); 1919 const emptyEl = el.querySelector('.pp-empty'); 1920 const loadingEl = el.querySelector('.pp-loading'); 1921 1922 grid.innerHTML = ''; 1923 emptyEl.style.display = 'none'; 1924 loadingEl.style.display = 'block'; 1925 1926 try { 1927 const user = getCurrentUser(); 1928 const userId = user?.id || user?.userId; 1929 let url = `/api/projects?limit=30&sort=newest`; 1930 if (userId) url += `&creator_id=${userId}`; 1931 if (search) url += `&search=${encodeURIComponent(search)}`; 1932 1933 const result = await apiClient.get(url, { skipErrorToast: true }); 1934 const projects = result?.data?.projects || result?.projects || []; 1935 1936 // Cache the default (no search) result for instant re-open 1937 if (!search) _projectsCache = projects; 1938 1939 _renderProjectGrid(projects); 1940 } catch (err) { 1941 console.error('Failed to load projects:', err); 1942 emptyEl.style.display = 'block'; 1943 } finally { 1944 loadingEl.style.display = 'none'; 1945 } 1946} 1947 1948function togglePollComposer(container) { 1949 const composer = container.querySelector('#pollComposer'); 1950 const btn = container.querySelector('#pollToggleBtn'); 1951 const composeInput = container.querySelector('#composeInput'); 1952 if (!composer) return; 1953 1954 state.pollComposerActive = !state.pollComposerActive; 1955 1956 if (state.pollComposerActive) { 1957 composer.classList.add('active'); 1958 btn?.classList.add('active-tool'); 1959 if (composeInput) composeInput.placeholder = 'Ask a question for your poll...'; 1960 setTimeout(() => composer.querySelector('.poll-option-input')?.focus(), 100); 1961 } else { 1962 composer.classList.remove('active'); 1963 btn?.classList.remove('active-tool'); 1964 if (composeInput) composeInput.placeholder = 'Share your latest creation, a behind-the-scenes look, or ask the community...'; 1965 resetPollComposer(container); 1966 } 1967 updatePostButton(container); 1968} 1969 1970function addPollOption(container) { 1971 if (state.pollOptionCount >= 4) return; 1972 state.pollOptionCount++; 1973 const editor = container.querySelector('#pollOptionsEditor'); 1974 if (!editor) return; 1975 1976 const row = document.createElement('div'); 1977 row.className = 'poll-option-input-row'; 1978 row.innerHTML = ` 1979 <div class="poll-option-number">${state.pollOptionCount}</div> 1980 <input type="text" class="poll-option-input" placeholder="Option ${state.pollOptionCount}" maxlength="80"> 1981 <button class="poll-option-img-btn" title="Add image">${icon('image', { size: 16 })}</button> 1982 <button class="poll-option-remove-btn">${icon('close', { size: 14 })}</button>`; 1983 editor.appendChild(row); 1984 row.querySelector('input').focus(); 1985 1986 row.querySelector('.poll-option-remove-btn').addEventListener('click', () => { 1987 // Rebuild pollOptionMedia indices after removal 1988 const idx = Array.from(editor.querySelectorAll('.poll-option-input-row')).indexOf(row); 1989 row.remove(); 1990 state.pollOptionCount--; 1991 // Shift media indices down 1992 const newMedia = {}; 1993 const allRows = editor.querySelectorAll('.poll-option-input-row'); 1994 allRows.forEach((r, i) => { 1995 r.querySelector('.poll-option-number').textContent = i + 1; 1996 r.querySelector('.poll-option-input').placeholder = `Option ${i + 1}${i === 0 ? ' (add text or image)' : ''}`; 1997 // Preserve media for rows that existed before the removed one 1998 const oldIdx = i >= idx ? i + 1 : i; 1999 if (state.pollOptionMedia[oldIdx]) { 2000 newMedia[i] = state.pollOptionMedia[oldIdx]; 2001 } 2002 }); 2003 state.pollOptionMedia = newMedia; 2004 container.querySelector('#addOptionBtn')?.classList.remove('disabled'); 2005 updatePostButton(container); 2006 }); 2007 2008 if (state.pollOptionCount >= 4) { 2009 container.querySelector('#addOptionBtn')?.classList.add('disabled'); 2010 } 2011} 2012 2013function _openPollLightbox(src) { 2014 // Remove existing lightbox 2015 document.querySelector('.poll-lightbox-overlay')?.remove(); 2016
2017 const overlay = document.createElement('div'); 2018 overlay.className = 'poll-lightbox-overlay'; 2019 2020 // Determine if it's a video or audio by extension 2021 const isVideo = /\.(mp4|webm|mov)(\?|#|$)/i.test(src); 2022 const isAudio = /\.(mp3|wav|ogg|aac|m4a)(\?|#|$)/i.test(src); 2023 2024 let mediaHtml; 2025 if (isVideo) { 2026 mediaHtml = `<video src="${src}" controls autoplay playsinline style="max-width:90vw;max-height:80vh;border-radius:12px;"></video>`; 2027 } else if (isAudio) { 2028 mediaHtml = `<div class="poll-lb-audio"><svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#DC2626" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg><audio src="${src}" controls autoplay style="width:100%;margin-top:16px;"></audio></div>`; 2029 } else { 2030 // Use lightbox quality for the full image 2031 const lbUrl = (src.includes('media.arturum.ai') || src.includes('r2.dev')) 2032 ? (getOptimizedImageUrl(src, 'lightbox') || src) : src; 2033 mediaHtml = `<img src="${lbUrl}" alt="" style="max-width:90vw;max-height:85vh;border-radius:12px;object-fit:contain;" />`; 2034 } 2035 2036 overlay.innerHTML = ` 2037 <div class="poll-lb-content">${mediaHtml}</div> 2038 <button class="poll-lb-close">â</button>`; 2039 document.body.appendChild(overlay); 2040 document.body.style.overflow = 'hidden'; 2041 2042 const close = () => { overlay.remove(); document.body.style.overflow = ''; }; 2043 overlay.querySelector('.poll-lb-close').addEventListener('click', close); 2044 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 2045 document.addEventListener('keydown', function escHandler(e) { 2046 if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escHandler); } 2047 }); 2048} 2049 2050function _showPollMediaChooser(anchorBtn, row, optionIdx, container) { 2051 // Remove any existing chooser 2052 document.querySelector('.poll-media-chooser')?.remove(); 2053 2054 const chooser = document.createElement('div'); 2055 chooser.className = 'poll-media-chooser'; 2056 chooser.innerHTML = ` 2057 <button class="pmc-option" data-type="images">${icon('image', { size: 16 })} Image</button> 2058 <button class="pmc-option" data-type="videos">${icon('video', { size: 16 })} Video</button> 2059 <button class="pmc-option" data-type="audio">${icon('audio', { size: 16 })} Audio</button>`; 2060 2061 // Position below the button 2062 const rect = anchorBtn.getBoundingClientRect(); 2063 chooser.style.position = 'fixed'; 2064 chooser.style.top = `${rect.bottom + 4}px`; 2065 chooser.style.left = `${rect.left}px`; 2066 chooser.style.zIndex = '10001'; 2067 document.body.appendChild(chooser); 2068 2069 const closeChooser = () => { chooser.remove(); document.removeEventListener('click', outsideClose); }; 2070 const outsideClose = (e) => { if (!chooser.contains(e.target) && e.target !== anchorBtn) closeChooser(); }; 2071 setTimeout(() => document.addEventListener('click', outsideClose), 0); 2072 2073 chooser.addEventListener('click', (e) => { 2074 const btn = e.target.closest('.pmc-option'); 2075 if (!btn) return; 2076 const pickerType = btn.dataset.type; 2077 closeChooser(); 2078 2079 if (!mediaPicker) mediaPicker = new MediaPickerModal(); 2080 mediaPicker.open({ 2081 type: pickerType, 2082 maxSelect: 1, 2083 onConfirm: (items) => { 2084 if (items.length > 0) { 2085 const item = items[0]; 2086 state.pollOptionMedia[optionIdx] = { 2087 url: item.url, 2088 thumbnail_url: item.thumbnail_url || item.url, 2089 media_type: item.media_type || 'image', 2090 }; 2091 _renderPollOptionThumb(row, optionIdx); 2092 } 2093 }, 2094 }); 2095 }); 2096} 2097 2098function _renderPollOptionThumb(row, optionIdx) { 2099 // Remove existing thumb if any 2100 const existing = row.querySelector('.poll-option-thumb-preview'); 2101 if (existing) existing.remove(); 2102 2103 const media = state.pollOptionMedia[optionIdx]; 2104 if (!media) return; 2105 2106 const imgBtn = row.querySelector('.poll-option-img-btn'); 2107 const thumb = document.createElement('div'); 2108 thumb.className = 'poll-option-thumb-preview'; 2109 const thumbUrl = media.thumbnail_url || media.url; 2110 const optimized = thumbUrl && (thumbUrl.includes('media.arturum.ai') || thumbUrl.includes('r2.dev')) 2111 ? (getOptimizedImageUrl(thumbUrl, 'thumb') || thumbUrl) : thumbUrl; 2112 thumb.innerHTML = ` 2113 <img src="${optimized}" alt="" /> 2114 <button class="poll-thumb-remove" title="Remove">â</button>`; 2115 if (imgBtn) { 2116 imgBtn.style.display = 'none'; 2117 imgBtn.insertAdjacentElement('afterend', thumb); 2118 } else { 2119 row.appendChild(thumb); 2120 } 2121 thumb.querySelector('.poll-thumb-remove').addEventListener('click', (e) => { 2122 e.stopPropagation(); 2123 delete state.pollOptionMedia[optionIdx]; 2124 thumb.remove(); 2125 if (imgBtn) imgBtn.style.display = ''; 2126 }); 2127} 2128 2129function resetPollComposer(container) { 2130 const editor = container.querySelector('#pollOptionsEditor'); 2131 if (!editor) return; 2132 2133 editor.innerHTML = ` 2134 <div class="poll-option-input-row"> 2135 <div class="poll-option-number">1</div> 2136 <input type="text" class="poll-option-input" placeholder="Option 1 (add text or image)" maxlength="80"> 2137 <button class="poll-option-img-btn" title="Add image">${icon('image', { size: 16 })}</button> 2138 </div> 2139 <div class="poll-option-input-row"> 2140 <div class="poll-option-number">2</div> 2141 <input type="text" class="poll-option-input" placeholder="Option 2" maxlength="80"> 2142 <button class="poll-option-img-btn" title="Add image">${icon('image', { size: 16 })}</button> 2143 </div>`; 2144 state.pollOptionCount = 2; 2145 state.pollOptionMedia = {}; 2146 container.querySelector('#addOptionBtn')?.classList.remove('disabled'); 2147} 2148 2149async function submitPost(container) { 2150 const composeInput = container.querySelector('#composeInput'); 2151 const postBtn = container.querySelector('#postBtn'); 2152 if (!composeInput || !postBtn || postBtn.disabled) return; 2153 2154 const caption = composeInput.value.trim(); 2155 const hasMedia = state.selectedMedia.length > 0; 2156 const hasProject = !!state.selectedProjectId; 2157 2158 // Need at least caption or media 2159 if (!caption && !hasMedia && !hasProject) return; 2160 2161 postBtn.disabled = true; 2162 postBtn.textContent = 'Posting...'; 2163 2164 try { 2165 const body = { 2166 caption: caption || '', 2167 type: state.pollComposerActive ? 'poll' : (hasProject ? 'promo' : 'showcase'), 2168 }; 2169 2170 // Attach media items 2171 if (hasMedia) { 2172 body.media = state.selectedMedia.map(m => { 2173 const item = { 2174 url: m.url, 2175 thumbnail_url: m.thumbnail_url || null, 2176 media_type: m.media_type, 2177 width: m.width || null, 2178 height: m.height || null, 2179 duration_ms: m.duration_ms || null, 2180 }; 2181 // Pass per-item audio metadata (title, prompt, model) for each audio track 2182 if (m.media_type === 'audio' && (m._name || m._prompt || m._model)) { 2183 item.audio_meta = { 2184 title: m._name || null, 2185 prompt: m._prompt || null, 2186 model: m._model || null, 2187 }; 2188 } 2189 return item; 2190 }); 2191 // Also set generation_meta from first audio item (for backward compat) 2192 const firstMedia = state.selectedMedia[0]; 2193 if (firstMedia?.media_type === 'audio' && (firstMedia._name || firstMedia._prompt || firstMedia._model)) { 2194 body.generation_meta = { 2195 title: firstMedia._name || null, 2196 prompt: firstMedia._prompt || null, 2197 model: firstMedia._model || null, 2198 }; 2199 } 2200 } 2201 2202 // Attach linked project 2203 if (hasProject) { 2204 body.linked_project_id = state.selectedProjectId; 2205 } 2206 2207 // Build poll data if active 2208 if (state.pollComposerActive) { 2209 const inputs = container.querySelectorAll('#pollOptionsEditor .poll-option-input'); 2210 const allLabels = Array.from(inputs).map(i => i.value.trim()); 2211 const options = [];
2212 allLabels.forEach((label, i) => { 2213 if (!label) return; 2214 const opt = { id: String(options.length), label }; 2215 const media = state.pollOptionMedia[i]; 2216 if (media) { 2217 opt.thumb_url = media.thumbnail_url || media.url; 2218 } 2219 options.push(opt); 2220 }); 2221 const duration = parseInt(container.querySelector('#pollDuration')?.value || '24', 10); 2222 body.poll_data = { options, duration_hours: duration }; 2223 } 2224 2225 // Extract hashtags from caption 2226 const hashtagMatches = caption.match(/#\w+/g); 2227 if (hashtagMatches) { 2228 body.tags = hashtagMatches.map(t => t.slice(1)); 2229 } 2230 2231 const result = await apiClient.post('/api/posts', body, { requiresAuth: true }); 2232 2233 if (result?.success) { 2234 composeInput.value = ''; 2235 composeInput.style.height = 'auto'; 2236 if (state.pollComposerActive) togglePollComposer(container); 2237 2238 // Clear media and project state 2239 state.selectedMedia = []; 2240 state.pollOptionMedia = {}; 2241 state.selectedProjectId = null; 2242 state.selectedProjectTitle = null; 2243 renderMediaPreview(container); 2244 2245 // Optimistically prepend the new post to the feed 2246 const apiPost = result.data?.post; 2247 if (apiPost) { 2248 const currentUser = getCurrentUser(); 2249 const feedPost = { 2250 id: apiPost.id, 2251 type: apiPost.type || 'showcase', 2252 caption: apiPost.caption || '', 2253 created_at: apiPost.created_at, 2254 milestone_title: apiPost.milestone_title || null, 2255 milestone_subtitle: apiPost.milestone_subtitle || null, 2256 media: (apiPost.media || []).map(m => ({ 2257 id: m.id, 2258 type: m.media_type || m.type, 2259 url: m.url, 2260 thumbnail_url: m.thumbnail_url || null, 2261 width: m.width || null, 2262 height: m.height || null, 2263 duration_ms: m.duration_ms || null, 2264 title: m.title || null, 2265 prompt: m.prompt || null, 2266 model: m.model || null, 2267 })), 2268 author: apiPost.author ? { 2269 id: apiPost.author.id, 2270 username: apiPost.author.username, 2271 display_name: apiPost.author.display_name, 2272 avatar_url: apiPost.author.avatar_url, 2273 level: apiPost.author.current_level || apiPost.author.level || 0, 2274 xp: apiPost.author.total_xp || apiPost.author.xp || 0, 2275 is_verified: !!apiPost.author.is_verified, 2276 streak_days: apiPost.author.current_streak || apiPost.author.streak_days || 0, 2277 } : { 2278 id: currentUser?.id, 2279 username: currentUser?.username || 'me', 2280 display_name: currentUser?.display_name || currentUser?.username || 'Me', 2281 avatar_url: currentUser?.avatar_url || null, 2282 level: 0, xp: 0, is_verified: false, streak_days: 0, 2283 }, 2284 generation_meta: apiPost.generation_meta || null, 2285 linked_project: null, 2286 poll: apiPost.poll_data ? { options: apiPost.poll_data.options || [], total_votes: 0, ends_at: null, user_vote: null } : null, 2287 engagement: { 2288 like_count: 0, comment_count: 0, remix_count: 0, share_count: 0, 2289 is_liked: false, is_bookmarked: false, 2290 }, 2291 comments_preview: [], 2292 tags: (apiPost.tags || []).map(t => t.startsWith('#') ? t : `#${t}`), 2293 }; 2294 2295 state.posts = [feedPost, ...state.posts]; 2296 const feedContainer = container.querySelector('#feedContainer'); 2297 if (feedContainer) { 2298 const newHtml = renderPostCard(feedPost); 2299 feedContainer.insertAdjacentHTML('afterbegin', newHtml); 2300 observeNewLazyVideos(feedContainer); 2301 } 2302 } 2303 showToast('Posted!', 'success'); 2304 } else { 2305 const msg = result?.error?.message || result?.message || 'Failed to post'; 2306 showToast(msg, 'error'); 2307 } 2308 } catch (err) { 2309 console.error('Failed to create post:', err); 2310 showToast(err?.message || 'Failed to post. Please try again.', 'error'); 2311 } finally { 2312 postBtn.disabled = false; 2313 postBtn.textContent = 'Post'; 2314 updatePostButton(container); 2315 } 2316} 2317 2318// ============================================================================ 2319// EVENT HANDLERS â Engagement (delegated) 2320// ============================================================================ 2321 2322function initEngagementHandlers(container) { 2323 container.addEventListener('click', (e) => { 2324 // Profile link (post author, comment author, suggested creators) 2325 const profileLink = e.target.closest('[data-username]'); 2326 if (profileLink) { 2327 e.preventDefault(); 2328 const username = profileLink.dataset.username; 2329 if (username) window.ARTURUM?.navigate(`/profile/${encodeURIComponent(username)}`); 2330 return; 2331 } 2332 2333 // Like button 2334 const likeBtn = e.target.closest('[data-action="like"]'); 2335 if (likeBtn) { 2336 e.preventDefault(); 2337 toggleLike(likeBtn); 2338 return; 2339 } 2340 2341 // Bookmark button 2342 const bookmarkBtn = e.target.closest('[data-action="bookmark"]'); 2343 if (bookmarkBtn) { 2344 e.preventDefault(); 2345 toggleBookmark(bookmarkBtn); 2346 return; 2347 } 2348 2349 // Comment focus 2350 const commentBtn = e.target.closest('[data-action="comment"]'); 2351 if (commentBtn) { 2352 const postId = commentBtn.dataset.postId; 2353 const postCard = container.querySelector(`.post-card[data-post-id="${postId}"]`); 2354 postCard?.querySelector('.comment-input')?.focus(); 2355 return; 2356 } 2357 2358 // Comment send (main input only â inline reply sends handled separately above) 2359 const sendBtn = e.target.closest('.comment-send-btn:not(.inline-reply-send)'); 2360 if (sendBtn) { 2361 const postId = sendBtn.dataset.postId; 2362 const input = container.querySelector(`.comment-input[data-post-id="${postId}"]:not(.inline-reply-input)`); 2363 if (input && input.value.trim()) { 2364 submitComment(postId, input.value.trim(), container); 2365 input.value = ''; 2366 } 2367 return; 2368 } 2369 2370 // Comment reply â show inline input directly under the comment 2371 const replyBtn = e.target.closest('.comment-reply-btn'); 2372 if (replyBtn) { 2373 const postId = replyBtn.dataset.postId; 2374 const commentId = replyBtn.dataset.commentId; 2375
2376 // Remove any existing inline reply inputs 2377 container.querySelectorAll('.inline-reply-row').forEach(el => el.remove()); 2378 2379 // Get username of the comment being replied to 2380 const commentItem = replyBtn.closest('.comment-item'); 2381 const authorEl = commentItem?.querySelector('.comment-author'); 2382 const username = authorEl?.textContent?.trim() || ''; 2383 2384 // Find the replies container under this comment to insert the input 2385 const repliesContainer = commentItem?.querySelector(`.comment-replies[data-parent-id="${commentId}"]`); 2386 const currentUser = getCurrentUser(); 2387 2388 const inlineRow = document.createElement('div'); 2389 inlineRow.className = 'inline-reply-row'; 2390 inlineRow.innerHTML = ` 2391 ${currentUser ? avatarRing({ avatarUrl: currentUser.avatar_url, username: currentUser.username, level: 0, xp: 0, size: 'sm', showBadge: false }) : ''} 2392 <textarea class="inline-reply-input" placeholder="Reply to @${escapeHtml(username)}..." data-post-id="${postId}" data-reply-to="${commentId}" rows="1"></textarea> 2393 <button class="comment-send-btn inline-reply-send active" data-post-id="${postId}">Post</button> 2394 <button class="inline-reply-cancel">×</button> 2395 `; 2396 2397 if (repliesContainer) { 2398 repliesContainer.insertAdjacentElement('afterbegin', inlineRow); 2399 } else { 2400 // Fallback: insert after the comment meta 2401 const meta = commentItem?.querySelector('.comment-meta'); 2402 if (meta) meta.after(inlineRow); 2403 } 2404 2405 const inlineInput = inlineRow.querySelector('.inline-reply-input'); 2406 if (inlineInput) { 2407 inlineInput.value = username ? `@${username} ` : ''; 2408 inlineInput.focus(); 2409 } 2410 return; 2411 } 2412 2413 // Inline reply cancel 2414 const cancelBtn = e.target.closest('.inline-reply-cancel'); 2415 if (cancelBtn) { 2416 cancelBtn.closest('.inline-reply-row')?.remove(); 2417 return; 2418 } 2419 2420 // Inline reply send 2421 const inlineSendBtn = e.target.closest('.inline-reply-send'); 2422 if (inlineSendBtn) { 2423 const row = inlineSendBtn.closest('.inline-reply-row'); 2424 const inlineInput = row?.querySelector('.inline-reply-input'); 2425 if (inlineInput && inlineInput.value.trim()) { 2426 const postId = inlineInput.dataset.postId; 2427 const parentCommentId = inlineInput.dataset.replyTo; 2428 submitComment(postId, inlineInput.value.trim(), container, parentCommentId); 2429 row.remove(); 2430 } 2431 return; 2432 } 2433 2434 // Comment like â toggle via API 2435 const commentLikeBtn = e.target.closest('.comment-like-btn'); 2436 if (commentLikeBtn) { 2437 if (!isAuthenticated()) { showToast('Sign in to like', 'info'); return; } 2438 const commentId = commentLikeBtn.dataset.commentId; 2439 const postCard = commentLikeBtn.closest('.post-card'); 2440 const postId = postCard?.dataset.postId; 2441 if (!postId || !commentId) return; 2442 2443 const wasLiked = commentLikeBtn.classList.toggle('liked'); 2444 // Optimistic count update 2445 const currentCount = parseInt(commentLikeBtn.textContent.replace(/[^0-9]/g, '')) || 0; 2446 const newCount = wasLiked ? currentCount + 1 : Math.max(0, currentCount - 1); 2447 commentLikeBtn.textContent = `⥠${newCount}`; 2448 2449 apiClient.post(`/api/posts/${postId}/comments/${commentId}/like`, {}, { requiresAuth: true }) 2450 .catch(() => { 2451 // Revert on error 2452 commentLikeBtn.classList.toggle('liked'); 2453 commentLikeBtn.textContent = `⥠${currentCount}`; 2454 }); 2455 return; 2456 } 2457 2458 // Comment delete (own comments only) 2459 const commentDelBtn = e.target.closest('.comment-delete-btn'); 2460 if (commentDelBtn) { 2461 const commentId = commentDelBtn.dataset.commentId; 2462 const postId = commentDelBtn.dataset.postId; 2463 if (!commentId || !postId) return; 2464 const commentEl = commentDelBtn.closest('.comment-reply') || commentDelBtn.closest('.comment-item'); 2465 2466 // Remove from DOM immediately 2467 commentEl?.remove(); 2468 2469 // Update count 2470 const postCard = container.querySelector(`.post-card[data-post-id="${postId}"]`); 2471 const countEl = postCard?.querySelector('[data-action="comment"] span'); 2472 if (countEl) { 2473 const count = parseInt(countEl.textContent.replace(/[^0-9]/g, '')) || 0; 2474 if (count > 0) countEl.textContent = formatCount(count - 1); 2475 } 2476 2477 // Call API in background (skip for temp/optimistic comments) 2478 if (!commentId.startsWith('temp-')) { 2479 apiClient.delete(`/api/posts/${postId}/comments/${commentId}`, { requiresAuth: true }) 2480 .catch(() => showToast('Failed to delete comment', 'error')); 2481 } 2482 return; 2483 } 2484 2485 // Post media expand icon â lightbox 2486 const postExpandBtn = e.target.closest('.post-media-expand'); 2487 if (postExpandBtn && postExpandBtn.dataset.src) { 2488 e.stopPropagation(); 2489 _openPollLightbox(postExpandBtn.dataset.src); 2490 return; 2491 } 2492 2493 // Poll card expand icon â lightbox (small icon in corner, not the whole media area) 2494 const pollExpandBtn = e.target.closest('.poll-card-expand'); 2495 if (pollExpandBtn && pollExpandBtn.dataset.src) { 2496 e.stopPropagation(); 2497 _openPollLightbox(pollExpandBtn.dataset.src); 2498 return; 2499 } 2500 2501 // Poll option thumbnail â lightbox (text-only poll fallback) 2502 const pollThumb = e.target.closest('.poll-option-thumb'); 2503 if (pollThumb) { 2504 e.stopPropagation(); 2505 const src = pollThumb.dataset.src || pollThumb.querySelector('img')?.src; 2506 if (src) { 2507 _openPollLightbox(src); 2508 } 2509 return; 2510 } 2511 2512 // Poll vote â handles both .poll-option (text mode) and .poll-card (visual mode) 2513 const pollOption = e.target.closest('.poll-option') || e.target.closest('.poll-card'); 2514 if (pollOption) { 2515 const postId = pollOption.dataset.postId; 2516 const optionId = pollOption.dataset.optionId;
2517 votePoll(postId, optionId, container); 2518 return; 2519 } 2520 2521 // Try This Prompt 2522 const tryPrompt = e.target.closest('.post-try-prompt'); 2523 if (tryPrompt) { 2524 const prompt = tryPrompt.dataset.prompt; 2525 const model = tryPrompt.dataset.model; 2526 if (prompt) { 2527 const params = new URLSearchParams({ prompt, from: 'feed' }); 2528 if (model) params.set('model', model); 2529 window.ARTURUM?.navigate(`/create?${params.toString()}`); 2530 } 2531 return; 2532 } 2533 2534 // Re-create button â navigate to /create with prompt + model 2535 const remixBtn = e.target.closest('[data-action="remix"]'); 2536 if (remixBtn) { 2537 const postId = remixBtn.dataset.postId; 2538 handleRecreate(postId, container); 2539 return; 2540 } 2541 2542 // Share button â native share or copy link 2543 const shareBtn = e.target.closest('[data-action="share"]'); 2544 if (shareBtn) { 2545 const postId = shareBtn.dataset.postId; 2546 const postUrl = `${window.location.origin}/community/post/${postId}`; 2547 const cached = postDataCache[postId]; 2548 const caption = cached?.caption || ''; 2549 const shareText = caption.length > 100 ? caption.substring(0, 100) + 'â¦' : caption; 2550 2551 if (navigator.share) { 2552 navigator.share({ title: 'Arturum', text: shareText || 'Check out this post on Arturum', url: postUrl }).catch(() => {}); 2553 } else { 2554 navigator.clipboard.writeText(postUrl).then(() => { 2555 showToast('Link copied to clipboard', 'success'); 2556 }).catch(() => { 2557 showToast('Could not copy link', 'error'); 2558 }); 2559 } 2560 return; 2561 } 2562 2563 // Post more menu â delete option 2564 const moreBtn = e.target.closest('.post-more-btn'); 2565 if (moreBtn) { 2566 e.stopPropagation(); 2567 togglePostMenu(moreBtn); 2568 return; 2569 } 2570 2571 // Edit post from dropdown 2572 const editBtn = e.target.closest('.post-menu-edit'); 2573 if (editBtn) { 2574 const postId = editBtn.dataset.postId; 2575 if (postId) startEditPost(postId, container); 2576 return; 2577 } 2578 2579 // Delete post from dropdown 2580 const deleteBtn = e.target.closest('.post-menu-delete'); 2581 if (deleteBtn) { 2582 const postId = deleteBtn.dataset.postId; 2583 if (postId) confirmDeletePost(postId, container); 2584 return; 2585 } 2586 2587 // Follow button (sidebar) 2588 const followBtn = e.target.closest('.btn-follow'); 2589 if (followBtn) { 2590 toggleFollow(followBtn); 2591 return; 2592 } 2593 2594 // Promo card click â navigate to project 2595 const promoCard = e.target.closest('.post-promo-card'); 2596 if (promoCard) { 2597 // If clicking the play button or video, toggle video playback 2598 const promoPlayBtn = e.target.closest('.promo-play-btn'); 2599 const promoVideo = promoCard.querySelector('video'); 2600 if (promoPlayBtn || e.target.tagName === 'VIDEO') { 2601 if (promoVideo) { 2602 // Lazy-load: ensure src is set before playing 2603 if (!promoVideo.src && promoVideo.dataset.src) { 2604 promoVideo.src = promoVideo.dataset.src; 2605 } 2606 if (promoVideo.paused) { 2607 promoVideo.play(); 2608 const pb = promoCard.querySelector('.promo-play-btn'); 2609 if (pb) pb.style.display = 'none'; 2610 } else { 2611 promoVideo.pause(); 2612 const pb = promoCard.querySelector('.promo-play-btn'); 2613 if (pb) pb.style.display = ''; 2614 } 2615 } 2616 return; 2617 } 2618 // Otherwise navigate to the project page 2619 const projectId = promoCard.dataset.projectId; 2620 if (projectId) window.ARTURUM?.navigate(`/project/${projectId}`); 2621 return; 2622 } 2623 2624 // Audio carousel arrow navigation 2625 const audioCarouselArrow = e.target.closest('.audio-carousel-arrow'); 2626 if (audioCarouselArrow) { 2627 e.stopPropagation(); 2628 const carousel = audioCarouselArrow.closest('.audio-carousel'); 2629 if (carousel) navigateAudioCarousel(carousel, parseInt(audioCarouselArrow.dataset.dir)); 2630 return; 2631 } 2632 2633 // Audio carousel dot click 2634 const audioCarouselDot = e.target.closest('.audio-carousel-dot'); 2635 if (audioCarouselDot) { 2636 e.stopPropagation(); 2637 const carousel = audioCarouselDot.closest('.audio-carousel'); 2638 if (carousel) goToAudioSlide(carousel, parseInt(audioCarouselDot.dataset.index)); 2639 return; 2640 } 2641 2642 // Carousel arrow navigation 2643 const carouselArrow = e.target.closest('.carousel-arrow'); 2644 if (carouselArrow) { 2645 e.stopPropagation(); 2646 const carousel = carouselArrow.closest('.post-carousel'); 2647 if (carousel) navigateCarousel(carousel, parseInt(carouselArrow.dataset.dir)); 2648 return; 2649 } 2650 2651 // Carousel dot click 2652 const carouselDot = e.target.closest('.carousel-dot'); 2653 if (carouselDot) { 2654 e.stopPropagation(); 2655 const carousel = carouselDot.closest('.post-carousel'); 2656 if (carousel) goToSlide(carousel, parseInt(carouselDot.dataset.index)); 2657 return; 2658 } 2659 2660 // Video mute/unmute toggle 2661 const muteBtn = e.target.closest('.video-mute-btn'); 2662 if (muteBtn) { 2663 e.stopPropagation(); 2664 const wrapper = muteBtn.closest('.post-media-video, .carousel-slide'); 2665 const video = wrapper?.querySelector('video'); 2666 if (video) { 2667 video.muted = !video.muted; 2668 muteBtn.classList.toggle('muted', video.muted); 2669 muteBtn.title = video.muted ? 'Unmute' : 'Mute'; 2670 } 2671 return; 2672 } 2673 2674 // Video play/pause â standalone or inside carousel slide 2675 const videoSlide = e.target.closest('.post-media-video') || e.target.closest('.carousel-slide'); 2676 if (videoSlide) { 2677 const video = videoSlide.querySelector('video'); 2678 if (video) { 2679 // Lazy-load: ensure src is set before playing 2680 if (!video.src && video.dataset.src) { 2681 video.src = video.dataset.src; 2682 video.preload = 'metadata'; 2683 } 2684 const playBtn = videoSlide.querySelector('.media-play-btn'); 2685 if (video.paused) { 2686 // Pause all other videos in the feed 2687 container.querySelectorAll('video').forEach(v => { 2688 if (v !== video && !v.paused) { 2689 v.pause(); 2690 const pb = v.closest('.post-media-video, .carousel-slide')?.querySelector('.media-play-btn'); 2691 if (pb) pb.classList.remove('hidden'); 2692 } 2693 }); 2694 video.play().catch(() => {}); 2695 if (playBtn) playBtn.classList.add('hidden'); 2696 } else { 2697 video.pause(); 2698 if (playBtn) playBtn.classList.remove('hidden'); 2699 } 2700 return; 2701 } 2702 } 2703 2704 // Audio play/pause (cover play btn OR bottom play/pause btn) 2705 const audioPlayBtn = e.target.closest('.post-audio-play-btn') || e.target.closest('.audio-card-pp'); 2706 if (audioPlayBtn) { 2707 const audioCard = audioPlayBtn.closest('.post-audio-card'); 2708 if (!audioCard) return; 2709 const src = audioCard.querySelector('.post-audio-play-btn')?.dataset.src; 2710 if (!src) return; 2711 // Reuse or create <audio> element 2712 let audioEl = audioCard.querySelector('audio'); 2713 if (!audioEl) { 2714 audioEl = document.createElement('audio'); 2715 audioEl.src = src; 2716 audioEl.preload = 'metadata'; 2717 audioCard.appendChild(audioEl); 2718 const _setPlaying = (playing) => { 2719 audioCard.classList.toggle('playing', playing); 2720 audioCard.querySelector('.post-audio-play-btn')?.classList.toggle('playing', playing); 2721 audioCard.querySelector('.audio-card-pp')?.classList.toggle('playing', playing); 2722 }; 2723 audioEl.addEventListener('ended', () => { _setPlaying(false); audioEl.currentTime = 0; }); 2724 audioEl.addEventListener('timeupdate', () => { 2725 if (!audioEl.duration) return; 2726 const cur = audioEl.currentTime; 2727 const dur = audioEl.duration; 2728 const pct = (cur / dur) * 100; 2729 const fill = audioCard.querySelector('.audio-card-bar-fill'); 2730 if (fill) fill.style.width = pct + '%'; 2731 const curEl = audioCard.querySelector('.audio-card-current'); 2732 if (curEl) { const m = Math.floor(cur / 60); const s = Math.floor(cur % 60); curEl.textContent = `${m}:${String(s).padStart(2, '0')}`; } 2733 }); 2734 audioEl.addEventListener('loadedmetadata', () => { 2735 if (audioEl.duration && isFinite(audioEl.duration)) { 2736 const totalEl = audioCard.querySelector('.audio-card-total'); 2737 if (totalEl) { const m = Math.floor(audioEl.duration / 60); const s = Math.floor(audioEl.duration % 60); totalEl.textContent = `${m}:${String(s).padStart(2, '0')}`; } 2738 } 2739 }); 2740 } 2741 if (audioEl.paused) { 2742 // Pause any other playing audio 2743 container.querySelectorAll('.post-audio-card audio').forEach(a => { 2744 if (a !== audioEl && !a.paused) { 2745 a.pause(); 2746 const oc = a.closest('.post-audio-card'); 2747 oc?.classList.remove('playing'); 2748 oc?.querySelector('.post-audio-play-btn')?.classList.remove('playing'); 2749 oc?.querySelector('.audio-card-pp')?.classList.remove('playing'); 2750 } 2751 }); 2752 audioEl.play().catch(() => {}); 2753 audioCard.classList.add('playing'); 2754 audioCard.querySelector('.post-audio-play-btn')?.classList.add('playing'); 2755 audioCard.querySelector('.audio-card-pp')?.classList.add('playing'); 2756 } else { 2757 audioEl.pause(); 2758 audioCard.classList.remove('playing'); 2759 audioCard.querySelector('.post-audio-play-btn')?.classList.remove('playing'); 2760 audioCard.querySelector('.audio-card-pp')?.classList.remove('playing'); 2761 } 2762 return; 2763 } 2764 2765 // Audio progress bar seek 2766 const audioProgress = e.target.closest('.audio-card-progress'); 2767 if (audioProgress) { 2768 const audioCard = audioProgress.closest('.post-audio-card'); 2769 const audioEl = audioCard?.querySelector('audio'); 2770 if (audioEl && audioEl.duration && isFinite(audioEl.duration)) { 2771 const rect = audioProgress.getBoundingClientRect(); 2772 const pct = (e.clientX - rect.left) / rect.width; 2773 audioEl.currentTime = pct * audioEl.duration; 2774 } 2775 return; 2776 } 2777 2778 // Audio volume toggle 2779 const volBtn = e.target.closest('.audio-card-vol'); 2780 if (volBtn) { 2781 const audioCard = volBtn.closest('.post-audio-card'); 2782 const audioEl = audioCard?.querySelector('audio'); 2783 if (audioEl) { 2784 audioEl.muted = !audioEl.muted; 2785 volBtn.classList.toggle('muted', audioEl.muted); 2786 } 2787 return; 2788 } 2789 2790 // Audio download 2791 const dlBtn = e.target.closest('.audio-download-btn'); 2792 if (dlBtn) { 2793 const src = dlBtn.dataset.src; 2794 if (src) { 2795 const a = document.createElement('a'); 2796 a.href = src; 2797 a.download = 'track.mp3'; 2798 a.target = '_blank'; 2799 document.body.appendChild(a); 2800 a.click(); 2801 a.remove(); 2802 } 2803 return; 2804 } 2805 }); 2806 2807 // Double-tap like on media 2808 container.addEventListener('dblclick', (e) => { 2809 const mediaEl = e.target.closest('.post-media'); 2810 if (!mediaEl) return; 2811 doubleTapLike(mediaEl, container); 2812 }); 2813
2814 // Comment input enter key (main input + inline reply inputs) 2815 container.addEventListener('keydown', (e) => { 2816 if (e.key === 'Enter' && !e.shiftKey) { 2817 // Main comment input 2818 const mainInput = e.target.closest('.comment-input'); 2819 if (mainInput && mainInput.value.trim()) { 2820 e.preventDefault(); 2821 submitComment(mainInput.dataset.postId, mainInput.value.trim(), container); 2822 mainInput.value = ''; 2823 return; 2824 } 2825 // Inline reply input 2826 const inlineInput = e.target.closest('.inline-reply-input'); 2827 if (inlineInput && inlineInput.value.trim()) { 2828 e.preventDefault(); 2829 submitComment(inlineInput.dataset.postId, inlineInput.value.trim(), container, inlineInput.dataset.replyTo); 2830 inlineInput.closest('.inline-reply-row')?.remove(); 2831 return; 2832 } 2833 } 2834 // Escape to cancel inline reply 2835 if (e.key === 'Escape') { 2836 const inlineRow = e.target.closest('.inline-reply-row'); 2837 if (inlineRow) inlineRow.remove(); 2838 } 2839 }); 2840 2841 // Comment input toggle send button active state + auto-grow textareas 2842 container.addEventListener('input', (e) => { 2843 if (e.target.classList.contains('comment-input') || e.target.classList.contains('inline-reply-input')) { 2844 const sendBtn = e.target.parentElement.querySelector('.comment-send-btn, .inline-reply-send'); 2845 if (sendBtn) sendBtn.classList.toggle('active', e.target.value.trim().length > 0); 2846 // Auto-grow textarea 2847 e.target.style.height = 'auto'; 2848 e.target.style.height = e.target.scrollHeight + 'px'; 2849 } 2850 }); 2851 2852 // Close post menu when clicking elsewhere â registered once per tab, 2853 // not per-mount. Without this guard the listener stacked on every visit 2854 // and every click site-wide ran N copies of querySelectorAll. 2855 if (!window.__arturumPostMenuClickWired) { 2856 document.addEventListener('click', closeAllPostMenus); 2857 window.__arturumPostMenuClickWired = true; 2858 } 2859} 2860 2861// ============================================================================ 2862// POST MORE MENU â Delete own posts 2863// ============================================================================ 2864 2865function closeAllPostMenus() { 2866 document.querySelectorAll('.post-menu-dropdown').forEach(m => m.remove()); 2867} 2868 2869function togglePostMenu(btn) { 2870 // If dropdown already open for this button, close it 2871 const existing = btn.parentElement.querySelector('.post-menu-dropdown'); 2872 if (existing) { 2873 existing.remove(); 2874 return; 2875 } 2876 2877 // Close any other open menus 2878 closeAllPostMenus(); 2879 2880 const postId = btn.dataset.postId; 2881 const authorId = btn.dataset.authorId; 2882 const currentUser = getCurrentUser(); 2883 2884 // Build menu items 2885 const items = []; 2886 2887 // Edit + Delete options â only for own posts 2888 if (currentUser && String(currentUser.id) === String(authorId)) { 2889 items.push(`<button class="post-menu-item post-menu-edit" data-post-id="${postId}"> 2890 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> 2891 Edit post 2892 </button>`); 2893 items.push(`<button class="post-menu-item post-menu-delete" data-post-id="${postId}"> 2894 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg> 2895 Delete post 2896 </button>`); 2897 } 2898 2899 // If no items to show (not the author), do nothing 2900 if (items.length === 0) return; 2901 2902 const menu = document.createElement('div'); 2903 menu.className = 'post-menu-dropdown'; 2904 menu.innerHTML = items.join(''); 2905 2906 // Position relative to the button's parent (.post-header) 2907 btn.parentElement.style.position = 'relative'; 2908 btn.parentElement.appendChild(menu); 2909} 2910 2911function startEditPost(postId, container) { 2912 closeAllPostMenus(); 2913 const postCard = container.querySelector(`.post-card[data-post-id="${postId}"]`); 2914 if (!postCard) return; 2915 2916 const captionDiv = postCard.querySelector('.post-caption'); 2917 const captionTextEl = captionDiv?.querySelector('.caption-text'); 2918 if (!captionDiv || !captionTextEl) return; 2919 2920 // Already editing? 2921 if (captionDiv.querySelector('.caption-edit-area')) return; 2922 2923 // Get raw text (convert <br> back to newlines) 2924 const rawText = captionTextEl.innerHTML.replace(/<br\s*\/?>/gi, '\n').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); 2925 2926 // Hide caption text, show edit area 2927 captionTextEl.style.display = 'none'; 2928 const editArea = document.createElement('div'); 2929 editArea.className = 'caption-edit-area'; 2930 editArea.innerHTML = ` 2931 <textarea class="caption-edit-textarea" rows="3">${escapeHtml(rawText)}</textarea> 2932 <div class="caption-edit-actions"> 2933 <button class="caption-edit-save">Save</button> 2934 <button class="caption-edit-cancel">Cancel</button> 2935 </div> 2936 `; 2937 captionDiv.appendChild(editArea); 2938 2939 const textarea = editArea.querySelector('.caption-edit-textarea'); 2940 textarea.focus(); 2941 textarea.style.height = 'auto'; 2942 textarea.style.height = textarea.scrollHeight + 'px'; 2943 2944 // Auto-grow 2945 textarea.addEventListener('input', () => { 2946 textarea.style.height = 'auto'; 2947 textarea.style.height = textarea.scrollHeight + 'px'; 2948 }); 2949 2950 // Save handler 2951 const save = async () => { 2952 const newCaption = textarea.value.trim(); 2953 if (!newCaption) { showToast('Caption cannot be empty', 'error'); return; } 2954 try { 2955 await apiClient.put(`/api/posts/${postId}`, { caption: newCaption }, { requiresAuth: true }); 2956 captionTextEl.innerHTML = escapeHtml(newCaption).replace(/\n/g, '<br>'); 2957 captionTextEl.style.display = ''; 2958 editArea.remove(); 2959 showToast('Post updated', 'success'); 2960 } catch (err) { 2961 showToast('Failed to save', 'error'); 2962 } 2963 }; 2964 2965 // Cancel handler 2966 const cancel = () => { 2967 captionTextEl.style.display = ''; 2968 editArea.remove(); 2969 }; 2970 2971 editArea.querySelector('.caption-edit-save').addEventListener('click', save); 2972 editArea.querySelector('.caption-edit-cancel').addEventListener('click', cancel); 2973 textarea.addEventListener('keydown', (e) => { 2974 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); } 2975 if (e.key === 'Escape') cancel(); 2976 }); 2977} 2978 2979function confirmDeletePost(postId, container) { 2980 closeAllPostMenus(); 2981 2982 if (isDemoPost(postId)) { 2983 _removePostCard(postId, container); 2984 showToast('Post deleted', 'success'); 2985 return; 2986 } 2987 2988 // Styled confirmation modal (matches /create page delete modal)
2989 const overlay = document.createElement('div'); 2990 overlay.className = 'delete-confirm-overlay'; 2991 overlay.innerHTML = ` 2992 <div class="delete-confirm-modal"> 2993 <div class="delete-confirm-header"> 2994 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg> 2995 <h3>Delete Post</h3> 2996 </div> 2997 <p class="delete-confirm-text">Are you sure you want to delete this post? This cannot be undone.</p> 2998 <div class="delete-confirm-actions"> 2999 <button class="delete-confirm-cancel">Cancel</button> 3000 <button class="delete-confirm-btn">Delete</button> 3001 </div> 3002 </div> 3003 `; 3004 3005 document.body.appendChild(overlay); 3006 3007 // Cancel 3008 overlay.querySelector('.delete-confirm-cancel').addEventListener('click', () => overlay.remove()); 3009 3010 // Click outside 3011 overlay.addEventListener('click', (e) => { 3012 if (e.target === overlay) overlay.remove(); 3013 }); 3014 3015 // Escape key 3016 const onEsc = (e) => { 3017 if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc); } 3018 }; 3019 document.addEventListener('keydown', onEsc); 3020 3021 // Confirm delete 3022 overlay.querySelector('.delete-confirm-btn').addEventListener('click', async () => { 3023 const btn = overlay.querySelector('.delete-confirm-btn'); 3024 btn.disabled = true; 3025 btn.textContent = 'Deletingâ¦'; 3026 3027 try { 3028 const res = await apiClient.request(`/api/posts/${postId}`, { 3029 method: 'DELETE', 3030 requiresAuth: true, 3031 }); 3032 3033 overlay.remove(); 3034 3035 if (res.success) { 3036 _removePostCard(postId, container); 3037 showToast('Post deleted', 'success'); 3038 } else { 3039 showToast(res.error?.message || 'Failed to delete post', 'error'); 3040 } 3041 } catch (err) { 3042 overlay.remove(); 3043 showToast(err?.message || 'Failed to delete post', 'error'); 3044 } 3045 }); 3046} 3047 3048function _removePostCard(postId, container) { 3049 const card = container.querySelector(`.post-card[data-post-id="${postId}"]`); 3050 if (card) { 3051 card.style.transition = 'opacity 0.3s, transform 0.3s'; 3052 card.style.opacity = '0'; 3053 card.style.transform = 'scale(0.95)'; 3054 setTimeout(() => card.remove(), 300); 3055 } 3056} 3057 3058// ============================================================================ 3059// RE-CREATE â Navigate to /create with prompt + model 3060// ============================================================================ 3061 3062function _navigateToCreate(prompt, model) { 3063 if (!prompt) { 3064 showToast('No prompt available for this media', 'error'); 3065 return; 3066 } 3067 const params = new URLSearchParams({ prompt, from: 'feed' }); 3068 if (model) params.set('model', model); 3069 window.ARTURUM?.navigate(`/create?${params.toString()}`); 3070} 3071 3072function handleRecreate(postId, container) { 3073 const cached = postDataCache[postId]; 3074 if (!cached || !cached.media || cached.media.length === 0) { 3075 // Fallback â try generation_meta directly 3076 if (cached?.generation_meta?.prompt) { 3077 _navigateToCreate(cached.generation_meta.prompt, cached.generation_meta.model); 3078 } else { 3079 showToast('No media data available', 'error'); 3080 } 3081 return; 3082 } 3083 3084 const { media, generation_meta } = cached; 3085 3086 // Single media â go directly 3087 if (media.length === 1) { 3088 const m = media[0]; 3089 const prompt = m.prompt || generation_meta?.prompt || ''; 3090 const model = m.model || generation_meta?.model || ''; 3091 _navigateToCreate(prompt, model); 3092 return; 3093 } 3094 3095 // Multiple media â show picker modal 3096 _showRecreatePicker(media, generation_meta); 3097} 3098 3099function _showRecreatePicker(media, generation_meta) { 3100 const overlay = document.createElement('div'); 3101 overlay.className = 'recreate-picker-overlay'; 3102 3103 const items = media.map((m, idx) => { 3104 const isAudio = m.type === 'audio'; 3105 const isVideo = m.type === 'video'; 3106 const prompt = m.prompt || (idx === 0 ? generation_meta?.prompt : null) || ''; 3107 const model = m.model || (idx === 0 ? generation_meta?.model : null) || ''; 3108 const title = m.title || ''; 3109 const promptPreview = prompt.length > 60 ? prompt.substring(0, 60) + 'â¦' : prompt; 3110 3111 let thumbHtml; 3112 if (isAudio) { 3113 const art = m.thumbnail_url ? (getOptimizedImageUrl(m.thumbnail_url, 'thumb') || m.thumbnail_url) : ''; 3114 thumbHtml = art 3115 ? `<img src="${art}" alt="" class="recreate-item-thumb">` 3116 : `<div class="recreate-item-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#EA2831" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></div>`; 3117 } else if (isVideo) { 3118 const poster = m.thumbnail_url 3119 ? (getOptimizedImageUrl(m.thumbnail_url, 'thumb') || m.thumbnail_url) 3120 : (getOptimizedVideoPoster(m.url, 'thumb') || ''); 3121 thumbHtml = `<img src="${poster}" alt="" class="recreate-item-thumb"> 3122 <div class="recreate-item-play">
3122<svg width="16" height="16" viewBox="0 0 24 24" fill="#fff" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg></div>`; 3123 } else { 3124 const src = getOptimizedImageUrl(m.thumbnail_url || m.url, 'thumb') || m.url || ''; 3125 thumbHtml = `<img src="${src}" alt="" class="recreate-item-thumb">`; 3126 } 3127 3128 const typeLabel = isAudio ? 'Audio' : isVideo ? 'Video' : 'Image'; 3129 const modelLabel = model ? escapeHtml(model) : ''; 3130 3131 return `<button class="recreate-item" data-prompt="${escapeHtml(prompt)}" data-model="${escapeHtml(model)}"> 3132 <div class="recreate-item-media">${thumbHtml}</div> 3133 <div class="recreate-item-info"> 3134 <div class="recreate-item-title">${escapeHtml(title || promptPreview || `${typeLabel} ${idx + 1}`)}</div> 3135 ${modelLabel ? `<div class="recreate-item-model">${modelLabel}</div>` : ''} 3136 </div> 3137 <svg class="recreate-item-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg> 3138 </button>`; 3139 }).join(''); 3140 3141 overlay.innerHTML = ` 3142 <div class="recreate-picker-modal"> 3143 <div class="recreate-picker-header"> 3144 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#EA2831" stroke-width="2">${ICONS.remix}</svg> 3145 <h3>Re-create</h3> 3146 <button class="recreate-picker-close">${icon('close', { size: 18 })}</button> 3147 </div> 3148 <p class="recreate-picker-subtitle">Choose which media to re-create on the Create page</p> 3149 <div class="recreate-picker-list">${items}</div> 3150 </div> 3151 `; 3152 3153 document.body.appendChild(overlay); 3154 3155 // Item click â navigate 3156 overlay.addEventListener('click', (e) => { 3157 const item = e.target.closest('.recreate-item'); 3158 if (item) { 3159 overlay.remove(); 3160 _navigateToCreate(item.dataset.prompt, item.dataset.model); 3161 return; 3162 } 3163 // Close 3164 if (e.target === overlay || e.target.closest('.recreate-picker-close')) { 3165 overlay.remove(); 3166 } 3167 }); 3168 3169 // Escape 3170 const onEsc = (e) => { 3171 if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc); } 3172 }; 3173 document.addEventListener('keydown', onEsc); 3174} 3175 3176function isDemoPost(postId) { 3177 return postId && postId.startsWith('demo-'); 3178} 3179 3180// ============================================================================ 3181// CAROUSEL 3182// ============================================================================ 3183 3184// ââ Audio Carousel ââ 3185 3186function navigateAudioCarousel(carousel, dir) { 3187 const slides = carousel.querySelectorAll('.audio-carousel-slide'); 3188 const current = carousel.querySelector('.audio-carousel-slide.active'); 3189 if (!current || slides.length < 2) return; 3190 const idx = parseInt(current.dataset.index); 3191 const next = idx + dir; 3192 if (next < 0 || next >= slides.length) return; 3193 goToAudioSlide(carousel, next); 3194} 3195 3196function goToAudioSlide(carousel, idx) { 3197 const slides = carousel.querySelectorAll('.audio-carousel-slide'); 3198 if (idx < 0 || idx >= slides.length) return; 3199 3200 slides.forEach(s => s.classList.remove('active')); 3201 slides[idx].classList.add('active'); 3202 3203 // Update dots 3204 carousel.querySelectorAll('.audio-carousel-dot').forEach(d => 3205 d.classList.toggle('active', parseInt(d.dataset.index) === idx)); 3206 3207 // Show/hide arrows 3208 const prev = carousel.querySelector('.audio-carousel-prev'); 3209 const next = carousel.querySelector('.audio-carousel-next'); 3210 if (prev) prev.style.display = idx === 0 ? 'none' : ''; 3211 if (next) next.style.display = idx === slides.length - 1 ? 'none' : ''; 3212} 3213 3214// ââ Image/Video Carousel ââ 3215 3216function navigateCarousel(carousel, dir) { 3217 const slides = carousel.querySelectorAll('.carousel-slide'); 3218 const current = carousel.querySelector('.carousel-slide.active'); 3219 if (!current || slides.length < 2) return; 3220 const idx = parseInt(current.dataset.index); 3221 const next = idx + dir; 3222 if (next < 0 || next >= slides.length) return; 3223 goToSlide(carousel, next); 3224} 3225 3226function goToSlide(carousel, idx) { 3227 const slides = carousel.querySelectorAll('.carousel-slide'); 3228 if (idx < 0 || idx >= slides.length) return; 3229 3230 // Pause any playing video in the current slide 3231 const currentSlide = carousel.querySelector('.carousel-slide.active'); 3232 if (currentSlide) { 3233 const vid = currentSlide.querySelector('video'); 3234 if (vid && !vid.paused) { 3235 vid.pause(); 3236 const pb = currentSlide.querySelector('.media-play-btn'); 3237 if (pb) pb.classList.remove('hidden'); 3238 } 3239 } 3240
3241 slides.forEach(s => s.classList.remove('active')); 3242 slides[idx].classList.add('active'); 3243 3244 // Update dots 3245 carousel.querySelectorAll('.carousel-dot').forEach(d => d.classList.toggle('active', parseInt(d.dataset.index) === idx)); 3246 3247 // Update counter 3248 const counter = carousel.querySelector('.carousel-counter'); 3249 if (counter) counter.textContent = `${idx + 1}/${slides.length}`; 3250 3251 // Show/hide arrows 3252 const prev = carousel.querySelector('.carousel-prev'); 3253 const next = carousel.querySelector('.carousel-next'); 3254 if (prev) prev.style.display = idx === 0 ? 'none' : ''; 3255 if (next) next.style.display = idx === slides.length - 1 ? 'none' : ''; 3256} 3257 3258function initCarouselSwipe(container) { 3259 let startX = 0, startY = 0, tracking = false, carousel = null; 3260 3261 container.addEventListener('touchstart', (e) => { 3262 carousel = e.target.closest('.post-carousel'); 3263 if (!carousel) return; 3264 startX = e.touches[0].clientX; 3265 startY = e.touches[0].clientY; 3266 tracking = true; 3267 }, { passive: true }); 3268 3269 container.addEventListener('touchend', (e) => { 3270 if (!tracking || !carousel) return; 3271 tracking = false; 3272 const dx = e.changedTouches[0].clientX - startX; 3273 const dy = e.changedTouches[0].clientY - startY; 3274 if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) { 3275 navigateCarousel(carousel, dx < 0 ? 1 : -1); 3276 } 3277 carousel = null; 3278 }, { passive: true }); 3279} 3280 3281function toggleLike(btn) { 3282 if (!isAuthenticated()) { 3283 showToast('Sign in to like posts', 'info'); 3284 return; 3285 } 3286 3287 const postId = btn.dataset.postId; 3288 const isLiked = btn.classList.toggle('liked'); 3289 const countEl = btn.querySelector('span'); 3290 const svg = btn.querySelector('svg'); 3291 let count = parseInt(countEl.textContent.replace(/[,K.M]/g, '')) || 0; 3292 3293 // Optimistic UI 3294 if (isLiked) { 3295 count++; 3296 svg.setAttribute('fill', 'currentColor'); 3297 svg.setAttribute('stroke-width', '0'); 3298 btn.classList.add('like-animate'); 3299 setTimeout(() => btn.classList.remove('like-animate'), 400); 3300 } else { 3301 count = Math.max(0, count - 1); 3302 svg.setAttribute('fill', 'none'); 3303 svg.setAttribute('stroke-width', '2'); 3304 } 3305 countEl.textContent = formatCount(count); 3306 3307 // Skip API call for demo posts 3308 if (isDemoPost(postId)) return; 3309 3310 // API call 3311 const method = isLiked ? 'post' : 'delete'; 3312 apiClient[method](`/api/posts/${postId}/like`, null, { requiresAuth: true }).catch(() => { 3313 // Revert on error 3314 btn.classList.toggle('liked'); 3315 countEl.textContent = formatCount(isLiked ? count - 1 : count + 1); 3316 if (!isLiked) { 3317 svg.setAttribute('fill', 'currentColor'); 3318 svg.setAttribute('stroke-width', '0'); 3319 } else { 3320 svg.setAttribute('fill', 'none'); 3321 svg.setAttribute('stroke-width', '2'); 3322 } 3323 }); 3324} 3325 3326function doubleTapLike(mediaEl, container) { 3327 const heart = mediaEl.querySelector('.double-tap-heart'); 3328 if (!heart) return; 3329 3330 heart.classList.remove('show'); 3331 void heart.offsetWidth; 3332 heart.classList.add('show'); 3333 3334 const postCard = mediaEl.closest('.post-card'); 3335 const likeBtn = postCard?.querySelector('[data-action="like"]'); 3336 if (likeBtn && !likeBtn.classList.contains('liked')) { 3337 toggleLike(likeBtn); 3338 } 3339 3340 setTimeout(() => heart.classList.remove('show'), 900); 3341} 3342 3343function toggleBookmark(btn) { 3344 if (!isAuthenticated()) { 3345 showToast('Sign in to bookmark posts', 'info'); 3346 return; 3347 } 3348 3349 const postId = btn.dataset.postId; 3350 const isBookmarked = btn.classList.toggle('bookmarked'); 3351 const svg = btn.querySelector('svg'); 3352 3353 // Optimistic UI 3354 svg.setAttribute('fill', isBookmarked ? '#EAB308' : 'none'); 3355 svg.setAttribute('stroke', isBookmarked ? '#EAB308' : 'currentColor'); 3356 3357 // Skip API call for demo posts 3358 if (isDemoPost(postId)) return; 3359 3360 const method = isBookmarked ? 'post' : 'delete'; 3361 apiClient[method](`/api/posts/${postId}/bookmark`, null, { requiresAuth: true }).catch(() => { 3362 btn.classList.toggle('bookmarked'); 3363 svg.setAttribute('fill', !isBookmarked ? '#EAB308' : 'none'); 3364 svg.setAttribute('stroke', !isBookmarked ? '#EAB308' : 'currentColor'); 3365 }); 3366} 3367 3368let _submitLock = false; 3369async function submitComment(postId, body, container, parentCommentId = null) { 3370 if (_submitLock) return; 3371 _submitLock = true; 3372 setTimeout(() => { _submitLock = false; }, 1000); 3373 3374 if (!isAuthenticated()) { 3375 showToast('Sign in to comment', 'info'); 3376 _submitLock = false;
3377 return; 3378 } 3379 3380 // Demo posts â show optimistic UI but skip API 3381 if (isDemoPost(postId)) { 3382 showToast('Comments on demo posts are not saved', 'info'); 3383 return; 3384 } 3385 3386 const user = getCurrentUser(); 3387 const postCard = container.querySelector(`.post-card[data-post-id="${postId}"]`); 3388 if (!postCard) return; 3389 3390 // Optimistic: add comment to DOM 3391 let commentsSection = postCard.querySelector('.post-comments-section'); 3392 if (!commentsSection) { 3393 commentsSection = document.createElement('div'); 3394 commentsSection.className = 'post-comments-section'; 3395 const engBar = postCard.querySelector('.post-engagement'); 3396 if (engBar) engBar.after(commentsSection); 3397 } 3398 3399 const tempId = 'temp-' + Date.now(); 3400 const uname = user?.username || 'You'; 3401 3402 // Use renderCommentThread for optimistic comment so it gets Reply/Delete buttons 3403 const optimisticComment = { 3404 id: tempId, 3405 author: { username: uname, avatar_url: user?.avatar_url || null, level: 0 }, 3406 body, 3407 created_at: new Date().toISOString(), 3408 like_count: 0, 3409 is_liked: false, 3410 replies: [], 3411 }; 3412 3413 if (parentCommentId) { 3414 // Reply â nest under parent comment 3415 const replyHtml = renderCommentThread(optimisticComment, postId, 1, 2); 3416 const repliesContainer = commentsSection.querySelector(`.comment-replies[data-parent-id="${parentCommentId}"]`); 3417 if (repliesContainer) { 3418 repliesContainer.insertAdjacentHTML('beforeend', replyHtml); 3419 } else { 3420 commentsSection.insertAdjacentHTML('beforeend', replyHtml); 3421 } 3422 } else { 3423 // Top-level comment 3424 const commentHtml = renderCommentThread(optimisticComment, postId, 0, 2); 3425 commentsSection.insertAdjacentHTML('beforeend', commentHtml); 3426 } 3427 3428 // Update count 3429 const countEl = postCard.querySelector('[data-action="comment"] span'); 3430 if (countEl) { 3431 const count = parseInt(countEl.textContent.replace(/[,K.M]/g, '')) || 0; 3432 countEl.textContent = formatCount(count + 1); 3433 } 3434 3435 try { 3436 const payload = { body }; 3437 if (parentCommentId) payload.parent_comment_id = parentCommentId; 3438 await apiClient.post(`/api/posts/${postId}/comments`, payload, { requiresAuth: true }); 3439 } catch (err) { 3440 // Remove optimistic comment on error 3441 const tempComment = commentsSection.querySelector(`[data-comment-id="${tempId}"]`); 3442 tempComment?.remove(); 3443 showToast('Failed to post comment', 'error'); 3444 } 3445} 3446 3447async function votePoll(postId, optionId, container) { 3448 if (!isAuthenticated()) { 3449 showToast('Sign in to vote', 'info'); 3450 return; 3451 } 3452 3453 const pollEl = container.querySelector(`.post-poll[data-post-id="${postId}"]`); 3454 if (!pollEl || pollEl.classList.contains('voted')) return; 3455 3456 pollEl.classList.add('voted'); 3457 3458 // Gather all option elements (both text and card modes) 3459 const options = pollEl.querySelectorAll('.poll-option, .poll-card'); 3460 options.forEach(opt => { 3461 if (opt.dataset.optionId === optionId) { 3462 opt.classList.add('voted'); 3463 } 3464 }); 3465 3466 // Skip API call for demo posts 3467 if (isDemoPost(postId)) { 3468 _applyPollResults(pollEl, options, optionId, null); 3469 return; 3470 } 3471 3472 try { 3473 const result = await apiClient.post(`/api/posts/${postId}/vote`, { option_id: optionId }, { requiresAuth: true }); 3474 if (result?.success && result.data) { 3475 _applyPollResults(pollEl, options, optionId, result.data); 3476 } else { 3477 _applyPollResults(pollEl, options, optionId, null); 3478 } 3479 } catch (err) { 3480 if (err?.message?.includes('already voted')) { 3481 showToast('You already voted on this poll', 'info'); 3482 } else { 3483 showToast('Failed to vote', 'error'); 3484 } 3485 pollEl.classList.remove('voted'); 3486 options.forEach(opt => opt.classList.remove('voted')); 3487 } 3488} 3489 3490function _applyPollResults(pollEl, options, votedOptionId, apiData) { 3491 let totalVotes = 0; 3492 const voteMap = {}; 3493 3494 if (apiData && apiData.options) { 3495 for (const opt of apiData.options) { 3496 voteMap[opt.id] = opt.vote_count || 0; 3497 totalVotes += opt.vote_count || 0; 3498 } 3499 } else { 3500 options.forEach(opt => { 3501 voteMap[opt.dataset.optionId] = opt.dataset.optionId === votedOptionId ? 1 : 0; 3502 totalVotes += voteMap[opt.dataset.optionId]; 3503 }); 3504 } 3505 3506 options.forEach(opt => { 3507 const oid = opt.dataset.optionId; 3508 const count = voteMap[oid] || 0; 3509 const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0; 3510 const isCard = opt.classList.contains('poll-card'); 3511 3512 if (isCard) { 3513 // Visual card mode 3514 const barFill = opt.querySelector('.poll-card-bar-fill'); 3515 const pctEl = opt.querySelector('.poll-card-pct'); 3516 const resultEl = opt.querySelector('.poll-card-result'); 3517 if (barFill) barFill.style.width = `${pct}%`; 3518 if (pctEl) pctEl.textContent = `${pct}%`; 3519 if (resultEl) resultEl.style.opacity = '1'; 3520 // Add check mark if voted 3521 if (opt.dataset.optionId === votedOptionId && !opt.querySelector('.poll-card-check')) { 3522 opt.insertAdjacentHTML('beforeend', '<div class="poll-card-check">
3522<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>'); 3523 } 3524 } else { 3525 // Text list mode 3526 const bar = opt.querySelector('.poll-bar'); 3527 const pctEl = opt.querySelector('.poll-option-pct'); 3528 if (bar) bar.style.width = `${pct}%`; 3529 if (pctEl) { 3530 pctEl.textContent = `${pct}%`; 3531 pctEl.style.display = 'inline'; 3532 } 3533 } 3534 }); 3535 3536 const metaEl = pollEl.querySelector('.poll-meta'); 3537 if (metaEl) { 3538 const existing = metaEl.textContent; 3539 const timeLeft = existing.includes('·') ? existing.split('·').slice(1).join('·').trim() : ''; 3540 metaEl.innerHTML = `${icon('poll', { size: 12 })} ${totalVotes} vote${totalVotes !== 1 ? 's' : ''}${timeLeft ? ` · ${timeLeft}` : ''}`; 3541 } 3542} 3543 3544function toggleFollow(btn) { 3545 if (!isAuthenticated()) { 3546 showToast('Sign in to follow creators', 'info'); 3547 return; 3548 } 3549 3550 const userId = btn.dataset.userId; 3551 const isFollowing = btn.classList.toggle('following'); 3552 btn.textContent = isFollowing ? 'Following' : 'Follow'; 3553 3554 const method = isFollowing ? 'post' : 'delete'; 3555 apiClient[method](`/api/users/${userId}/follow`, null, { requiresAuth: true }) 3556 .then(() => { 3557 // If followed from suggestions sidebar, remove the item after a brief delay 3558 if (isFollowing) { 3559 const suggestItem = btn.closest('.suggest-item'); 3560 if (suggestItem) { 3561 setTimeout(() => { 3562 suggestItem.style.transition = 'opacity 0.3s, max-height 0.3s, margin 0.3s, padding 0.3s'; 3563 suggestItem.style.opacity = '0'; 3564 suggestItem.style.maxHeight = '0'; 3565 suggestItem.style.marginTop = '0'; 3566 suggestItem.style.marginBottom = '0'; 3567 suggestItem.style.paddingTop = '0'; 3568 suggestItem.style.paddingBottom = '0'; 3569 suggestItem.style.overflow = 'hidden'; 3570 setTimeout(() => suggestItem.remove(), 300); 3571 }, 500); 3572 } 3573 } 3574 }) 3575 .catch(() => { 3576 btn.classList.toggle('following'); 3577 btn.textContent = !isFollowing ? 'Following' : 'Follow'; 3578 }); 3579} 3580 3581// ============================================================================ 3582// LAZY VIDEO LOADING 3583// ============================================================================ 3584 3585// Lazy-load + autoplay community feed videos. Videos play ONLY while in 3586// viewport: on entry the src is set (lazy), the video plays muted; on exit 3587// it pauses. This prevents N concurrent decoders running on scroll-past 3588// posts. We don't unload `src` (would cause blank cards on scroll-back) â 3589// just pause/resume. 3590function initLazyVideos(container) { 3591 if (state.videoObserver) state.videoObserver.disconnect(); 3592 3593 state.videoObserver = new IntersectionObserver((entries) => { 3594 entries.forEach(entry => { 3595 const video = entry.target; 3596 if (!(video instanceof HTMLVideoElement)) return; 3597 if (entry.isIntersecting) { 3598 if (video.dataset.src && !video.src) { 3599 video.src = video.dataset.src; 3600 video.preload = 'metadata'; 3601 } 3602 // Always autoplay muted when in view. Hover unmutes via the inline 3603 // onmouseenter/onmouseleave handlers in the markup. 3604 if (video.paused) { 3605 video.muted = true; 3606 video.play().catch(() => { /* autoplay policy may block â non-fatal */ }); 3607 } 3608 } else { 3609 // Out of view â pause so we don't have many decoders running. 3610 try { video.pause(); } catch (_) {} 3611 } 3612 }); 3613 }, { 3614 // Tighter margin so only meaningfully-visible videos play. 0% threshold 3615 // with rootMargin '0px' means strict viewport. 3616 rootMargin: '0px', 3617 threshold: 0.25, 3618 }); 3619 3620 // Observe all lazy videos currently in the DOM 3621 container.querySelectorAll('video.lazy-video').forEach(v => { 3622 state.videoObserver.observe(v); 3623 }); 3624} 3625 3626/** Observe newly added lazy videos (called after feed append / sidebar render) */ 3627function _ensureCommunityObserveAttached() { /* placeholder â observeNewLazyVideos remains below */ } 3628 3629/** Observe newly added lazy videos (called after feed append / sidebar render) */ 3630function observeNewLazyVideos(container) { 3631 if (!state.videoObserver) return; 3632 container.querySelectorAll('video.lazy-video').forEach(v => { 3633 // Observe regardless of whether src is set â the entry observer 3634 // handles play/pause based on viewport state, and the lazy-load 3635 // (src assignment) only happens once on first entry. 3636 state.videoObserver.observe(v); 3637 }); 3638} 3639 3640// ============================================================================ 3641// INFINITE SCROLL 3642// ============================================================================ 3643 3644function initInfiniteScroll(container) { 3645 const sentinel = container.querySelector('#loadingSkeleton'); 3646 if (!sentinel) return; 3647 3648 state.scrollObserver = new IntersectionObserver((entries) => {
3649 entries.forEach(entry => { 3650 if (entry.isIntersecting && !state.isLoading && state.nextCursor) { 3651 loadFeed(state.activeTab, true); 3652 } 3653 }); 3654 // Pre-fetch ~3 viewports before the sentinel scrolls into view so the 3655 // next batch is already on the wire â feels endless, no spinner pause. 3656 }, { rootMargin: '1200px' }); 3657 3658 state.scrollObserver.observe(sentinel); 3659} 3660 3661// ============================================================================ 3662// SIDEBAR TABS 3663// ============================================================================ 3664 3665function initSidebarTabs(container) { 3666 // Desktop: play video on hover (only on devices with hover capability) 3667 const hasHover = window.matchMedia('(hover: hover)').matches; 3668 if (hasHover) { 3669 container.addEventListener('mouseenter', (e) => { 3670 const wrap = e.target.closest('.comp-thumb-wrap'); 3671 if (!wrap) return; 3672 const video = wrap.querySelector('.comp-thumb-video'); 3673 const playBtn = wrap.querySelector('.comp-play-btn'); 3674 const muteBtn = wrap.querySelector('.comp-mute-btn'); 3675 if (video) { 3676 // Lazy-load: set src on first hover 3677 if (!video.src && video.dataset.src) { 3678 video.src = video.dataset.src; 3679 } 3680 // Must start muted â browsers block unmuted autoplay without user gesture 3681 video.muted = true; 3682 if (muteBtn) { muteBtn.classList.add('muted'); muteBtn.title = 'Unmute'; } 3683 video.play().catch(() => {}); 3684 video.classList.add('playing'); 3685 if (playBtn) playBtn.classList.add('hidden'); 3686 } 3687 }, true); 3688 container.addEventListener('mouseleave', (e) => { 3689 const wrap = e.target.closest('.comp-thumb-wrap'); 3690 if (!wrap) return; 3691 const video = wrap.querySelector('.comp-thumb-video'); 3692 const playBtn = wrap.querySelector('.comp-play-btn'); 3693 if (video) { 3694 video.pause(); 3695 video.currentTime = 0; 3696 video.muted = true; 3697 video.classList.remove('playing'); 3698 if (playBtn) playBtn.classList.remove('hidden'); 3699 } 3700 }, true); 3701 } 3702 3703 container.addEventListener('click', (e) => { 3704 // Competition video â expand/lightbox 3705 const expandBtn = e.target.closest('.comp-expand-btn'); 3706 if (expandBtn) { 3707 e.preventDefault(); 3708 e.stopPropagation(); 3709 const src = expandBtn.dataset.videoSrc; 3710 if (src) _openPollLightbox(src); 3711 return; 3712 } 3713 3714 // Competition video â mute/unmute 3715 const muteBtn = e.target.closest('.comp-mute-btn'); 3716 if (muteBtn) { 3717 e.preventDefault(); 3718 e.stopPropagation(); 3719 const wrap = muteBtn.closest('.comp-thumb-wrap'); 3720 const video = wrap?.querySelector('.comp-thumb-video'); 3721 if (video) { 3722 video.muted = !video.muted; 3723 muteBtn.classList.toggle('muted', video.muted); 3724 muteBtn.title = video.muted ? 'Unmute' : 'Mute'; 3725 } 3726 return; 3727 } 3728 3729 // Competition video â play/pause on thumbnail click 3730 const thumbWrap = e.target.closest('.comp-thumb-wrap'); 3731 if (thumbWrap) { 3732 e.preventDefault(); 3733 e.stopPropagation(); 3734 const video = thumbWrap.querySelector('.comp-thumb-video'); 3735 const playBtn = thumbWrap.querySelector('.comp-play-btn'); 3736 if (video) { 3737 // Lazy-load: set src on first tap 3738 if (!video.src && video.dataset.src) { 3739 video.src = video.dataset.src; 3740 } 3741 if (video.paused) { 3742 // Pause other comp videos 3743 container.querySelectorAll('.comp-thumb-video').forEach(v => { 3744 if (v !== video && !v.paused) { 3745 v.pause(); 3746 v.classList.remove('playing'); 3747 const pb = v.closest('.comp-thumb-wrap')?.querySelector('.comp-play-btn'); 3748 if (pb) pb.classList.remove('hidden'); 3749 } 3750 }); 3751 video.play().catch(() => {}); 3752 video.classList.add('playing'); 3753 if (playBtn) playBtn.classList.add('hidden'); 3754 } else { 3755 video.pause(); 3756 video.classList.remove('playing'); 3757 if (playBtn) playBtn.classList.remove('hidden'); 3758 } 3759 } 3760 return; 3761 } 3762 3763 // Profile link in sidebar (suggested creators) 3764 const profileLink = e.target.closest('[data-username]'); 3765 if (profileLink) { 3766 e.preventDefault(); 3767 const username = profileLink.dataset.username; 3768 if (username) window.ARTURUM?.navigate(`/profile/${encodeURIComponent(username)}`); 3769 return; 3770 } 3771 3772 // Competition link â SPA navigate to project page 3773 const compLink = e.target.closest('.comp-link'); 3774 if (compLink) { 3775 e.preventDefault(); 3776 const pid = compLink.dataset.projectId; 3777 if (pid) window.ARTURUM?.navigate(`/project/${pid}`); 3778 return; 3779 } 3780 3781 const sidebarTab = e.target.closest('.sidebar-tab'); 3782 if (!sidebarTab) return; 3783 3784 const target = sidebarTab.dataset.sidebar; 3785 if (!target || target === state.activeSidebarTab) return; 3786 3787 container.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active')); 3788 container.querySelectorAll('.sidebar-tab-content').forEach(c => c.classList.remove('active')); 3789 sidebarTab.classList.add('active'); 3790 const content = container.querySelector(`[data-sidebar-content="${target}"]`); 3791 if (content) content.classList.add('active'); 3792 state.activeSidebarTab = target; 3793 }); 3794} 3795 3796// ============================================================================ 3797// MOBILE SWIPE PANELS 3798// ============================================================================ 3799 3800function initMobileSwipe(container) { 3801 const sidebar = container.querySelector('.feed-sidebar'); 3802 if (!sidebar) return; 3803 3804 function openSidebar() { 3805 sidebar.classList.add('open'); 3806 } 3807 function closeSidebar() { 3808 sidebar.classList.remove('open'); 3809 } 3810 3811 // Add a back button inside the sidebar for mobile 3812 if (!sidebar.querySelector('.sidebar-back-btn')) { 3813 const backBtn = document.createElement('button'); 3814 backBtn.className = 'sidebar-back-btn'; 3815 backBtn.innerHTML = `${icon('chevronLeft', { size: 18 })} <span>Back to Feed</span>`;
3816 backBtn.addEventListener('click', closeSidebar); 3817 sidebar.prepend(backBtn); 3818 } 3819 3820 // Only enable touch gestures on mobile (<= 1024px) 3821 const isMobile = () => window.innerWidth <= 1024; 3822 3823 let startX = 0, startY = 0, tracking = false, isOnCarousel = false; 3824 3825 container.addEventListener('touchstart', (e) => { 3826 if (!isMobile()) return; 3827 // Don't track if touch starts on a carousel (let carousel handle it) 3828 isOnCarousel = !!e.target.closest('.post-carousel'); 3829 startX = e.touches[0].clientX; 3830 startY = e.touches[0].clientY; 3831 tracking = true; 3832 }, { passive: true }); 3833 3834 container.addEventListener('touchend', (e) => { 3835 if (!tracking || !isMobile()) return; 3836 tracking = false; 3837 if (isOnCarousel) { isOnCarousel = false; return; } 3838 3839 const dx = e.changedTouches[0].clientX - startX; 3840 const dy = e.changedTouches[0].clientY - startY; 3841 3842 // Need horizontal swipe > 60px and mostly horizontal (not vertical scroll) 3843 if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx)) return; 3844 3845 if (dx < 0 && !sidebar.classList.contains('open')) { 3846 // Swipe left â open sidebar 3847 openSidebar(); 3848 } else if (dx > 0 && sidebar.classList.contains('open')) { 3849 // Swipe right â close sidebar 3850 closeSidebar(); 3851 } 3852 isOnCarousel = false; 3853 }, { passive: true }); 3854} 3855 3856// ============================================================================ 3857// FAB 3858// ============================================================================ 3859 3860function initFAB(container) { 3861 const fab = container.querySelector('#fabCompose') || document.querySelector('#fabCompose'); 3862 if (!fab) return; 3863 3864 fab.addEventListener('click', () => { 3865 if (!isAuthenticated()) { 3866 showToast('Sign in to post', 'info'); 3867 return; 3868 } 3869 const composeInput = state.container?.querySelector('#composeInput'); 3870 if (composeInput) { 3871 composeInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); 3872 setTimeout(() => composeInput.focus(), 300); 3873 } 3874 }); 3875} 3876 3877// ============================================================================ 3878// SHOW MORE (Caption truncation) 3879// ============================================================================ 3880 3881function initShowMore(container) { 3882 if (!container) return; 3883 container.querySelectorAll('.caption-text').forEach(c => { 3884 if (c.dataset.showMoreInit) return; 3885 c.dataset.showMoreInit = '1'; 3886 // Wait a frame for layout 3887 requestAnimationFrame(() => { 3888 if (c.scrollHeight > c.clientHeight + 2) { 3889 const btn = document.createElement('span'); 3890 btn.className = 'show-more'; 3891 btn.textContent = 'Show more'; 3892 btn.addEventListener('click', () => { 3893 c.classList.toggle('expanded'); 3894 btn.textContent = c.classList.contains('expanded') ? 'Show less' : 'Show more'; 3895 }); 3896 c.parentElement.appendChild(btn); 3897 } 3898 }); 3899 }); 3900}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.