PageSourceSearch

https://www.arturum.ai/js/pages/community.js?v=1800000000

js arturum.ai collected 2026-10-05 15:19:21 UTC 174,345 bytes, 3,900 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
354    const safeAuthor = authorName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&quot;');
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">&times;</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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#039;/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.