Spaces:
Running
Running
| import { Component, ViewEncapsulation, OnInit } from '@angular/core'; | |
| import { CommonModule } from '@angular/common'; | |
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | |
| import { AuthService } from '../auth/auth.service'; | |
| import { firstValueFrom } from 'rxjs'; | |
| type Post = { | |
| id: number; | |
| userId: number; | |
| userName: string; | |
| title?: string; | |
| category?: string; | |
| tags?: string; | |
| body: string; | |
| createdAt: string; | |
| likeCount?: number; | |
| dislikeCount?: number; | |
| commentCount?: number; | |
| }; | |
| ({ | |
| selector: 'app-community', | |
| standalone: true, | |
| imports: [CommonModule, FormsModule, ReactiveFormsModule], | |
| templateUrl: './community.component.html', | |
| styleUrls: ['./community.component.scss'], | |
| encapsulation: ViewEncapsulation.None | |
| }) | |
| export class CommunityComponent implements OnInit { | |
| showModal = false; | |
| // modal bound fields | |
| title = ''; | |
| category = 'General Discussion'; | |
| tags = ''; | |
| tickers = ''; | |
| body = ''; | |
| posts: Post[] = []; | |
| loading = false; | |
| loadError = ''; | |
| // reply state per post | |
| replyDraft: Record<number, string> = {}; | |
| private replyOpen = new Set<number>(); | |
| private readonly baseUrl = | |
| location.hostname.endsWith('hf.space') | |
| ? 'https://pykara-pytrade-backend.hf.space' | |
| : 'http://127.0.0.1:5000'; | |
| constructor(private auth: AuthService) {} | |
| ngOnInit(): void { | |
| this.loadPosts(); | |
| } | |
| async loadPosts() { | |
| this.loading = true; | |
| this.loadError = ''; | |
| try { | |
| const res = await fetch(`${this.baseUrl}/posts?limit=50&offset=0`, { | |
| method: 'GET', | |
| credentials: 'omit', | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| throw new Error(err.error || err.message || `Failed (${res.status})`); | |
| } | |
| const data = await res.json(); | |
| const list = (data?.results as Post[]) ?? []; | |
| // Ensure counts exist for UI | |
| this.posts = list.map(p => ({ | |
| likeCount: 0, | |
| dislikeCount: 0, | |
| commentCount: 0, | |
| ...p | |
| })); | |
| } catch (e: any) { | |
| console.error('Load posts failed:', e); | |
| this.loadError = e?.message || 'Failed to load posts'; | |
| } finally { | |
| this.loading = false; | |
| } | |
| } | |
| splitTags(tags?: string): string[] { | |
| return (tags ?? '') | |
| .split(',') | |
| .map(t => t.trim()) | |
| .filter(t => t.length > 0); | |
| } | |
| toggleModal(show: boolean) { | |
| this.showModal = show; | |
| document.body.style.overflow = show ? 'hidden' : ''; | |
| } | |
| openThread(id: any) { | |
| const view = document.getElementById('threadView'); | |
| if (view) view.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| backToList() { | |
| const list = document.getElementById('threadList'); | |
| if (list) list.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| private async ensureAccessToken(): Promise<string | null> { | |
| let token = this.auth.getAccessToken(); | |
| if (!token) return null; | |
| if (this.auth.tokenExpired()) { | |
| try { | |
| token = await firstValueFrom(this.auth.refreshAccessToken()); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| return token || null; | |
| } | |
| private parseJwt(token: string): any | null { | |
| try { | |
| const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); | |
| const json = decodeURIComponent(atob(base64).split('').map(c => { | |
| return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); | |
| }).join('')); | |
| return JSON.parse(json); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| private getUserContextFromToken(token: string): { userId?: number; userName?: string } { | |
| const payload = this.parseJwt(token); | |
| if (!payload) return {}; | |
| const userId = payload.sub ? Number(payload.sub) : undefined; | |
| const userName = | |
| payload.name || | |
| payload.preferred_username || | |
| [payload.given_name, payload.family_name].filter(Boolean).join(' ') || | |
| undefined; | |
| return { userId, userName }; | |
| } | |
| isReplyOpen(postId: number): boolean { | |
| return this.replyOpen.has(postId); | |
| } | |
| toggleReply(postId: number) { | |
| if (this.replyOpen.has(postId)) { | |
| this.replyOpen.delete(postId); | |
| } else { | |
| this.replyOpen.add(postId); | |
| } | |
| } | |
| async likePost(p: Post) { | |
| // optimistic UI | |
| p.likeCount = (p.likeCount ?? 0) + 1; | |
| const token = await this.ensureAccessToken(); | |
| if (!token) return; // if not signed-in, keep optimistic or revert if you prefer | |
| try { | |
| await fetch(`${this.baseUrl}/posts/${p.id}/like`, { | |
| method: 'POST', | |
| credentials: 'omit', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| } | |
| }); | |
| } catch { | |
| // optionally revert UI on failure | |
| p.likeCount = Math.max(0, (p.likeCount ?? 1) - 1); | |
| } | |
| } | |
| async dislikePost(p: Post) { | |
| p.dislikeCount = (p.dislikeCount ?? 0) + 1; | |
| const token = await this.ensureAccessToken(); | |
| if (!token) return; | |
| try { | |
| await fetch(`${this.baseUrl}/posts/${p.id}/dislike`, { | |
| method: 'POST', | |
| credentials: 'omit', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| } | |
| }); | |
| } catch { | |
| p.dislikeCount = Math.max(0, (p.dislikeCount ?? 1) - 1); | |
| } | |
| } | |
| async submitReply(postId: number) { | |
| const draft = (this.replyDraft[postId] || '').trim(); | |
| if (!draft) return; | |
| const token = await this.ensureAccessToken(); | |
| if (!token) { | |
| alert('Please sign in to reply.'); | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`${this.baseUrl}/posts/${postId}/comments`, { | |
| method: 'POST', | |
| credentials: 'omit', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ body: draft }) | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| throw new Error(err.error || err.message || `Failed (${res.status})`); | |
| } | |
| // success: clear draft and bump counter | |
| delete this.replyDraft[postId]; | |
| const post = this.posts.find(x => x.id === postId); | |
| if (post) post.commentCount = (post.commentCount ?? 0) + 1; | |
| this.replyOpen.delete(postId); | |
| } catch (e: any) { | |
| console.error('Reply failed', e); | |
| alert(e?.message || 'Failed to post reply'); | |
| } | |
| } | |
| async publish() { | |
| if (!this.body.trim()) { | |
| alert('Post body is required'); | |
| return; | |
| } | |
| const token = await this.ensureAccessToken(); | |
| if (!token) { | |
| alert('Please sign in to publish.'); | |
| return; | |
| } | |
| const { userId, userName } = this.getUserContextFromToken(token); | |
| try { | |
| const res = await fetch(`${this.baseUrl}/posts`, { | |
| method: 'POST', | |
| credentials: 'omit', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ | |
| userId, // optional; backend may ignore and derive from JWT/DB | |
| userName, // optional; backend may ignore and derive from DB | |
| title: this.title, | |
| category: this.category, | |
| tags: this.tags, | |
| body: this.body | |
| }) | |
| }); | |
| if (res.status === 401) { | |
| alert('Session expired. Please sign in again.'); | |
| return; | |
| } | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| throw new Error(err.error || err.message || `Failed (${res.status})`); | |
| } | |
| // Clear and reload | |
| this.title = ''; | |
| this.category = 'General Discussion'; | |
| this.tags = ''; | |
| this.tickers = ''; | |
| this.body = ''; | |
| this.toggleModal(false); | |
| await this.loadPosts(); | |
| } catch (e: any) { | |
| console.error('Publish error:', e); | |
| alert(e?.message || 'Failed to publish'); | |
| } | |
| } | |
| } | |