Oviya commited on
Commit
4410abe
·
1 Parent(s): 832826c

update community page

Browse files
src/app/app-module.ts CHANGED
@@ -16,6 +16,7 @@ import { SignupModal } from './signup-modal/signup-modal';
16
  import { SigninModal } from './signin-modal/signin-modal';
17
  import { AuthInterceptor } from './auth/auth.interceptor';
18
  import { DashboardComponent } from './dashboard/dashboard.component';
 
19
 
20
 
21
  @NgModule({
@@ -35,7 +36,8 @@ import { DashboardComponent } from './dashboard/dashboard.component';
35
  MarkdownModule.forRoot(),
36
  SignupModal,
37
  SigninModal,
38
- DashboardComponent
 
39
 
40
  ],
41
  providers: [
 
16
  import { SigninModal } from './signin-modal/signin-modal';
17
  import { AuthInterceptor } from './auth/auth.interceptor';
18
  import { DashboardComponent } from './dashboard/dashboard.component';
19
+ import { CommunityComponent } from './community/community.component';
20
 
21
 
22
  @NgModule({
 
36
  MarkdownModule.forRoot(),
37
  SignupModal,
38
  SigninModal,
39
+ DashboardComponent,
40
+ CommunityComponent
41
 
42
  ],
43
  providers: [
src/app/app-routing-module.ts CHANGED
@@ -7,11 +7,13 @@ import { ToolspageComponent } from './toolspage/toolspage.component';
7
  import { Screenerpage } from './screenerpage/screenerpage';
8
  import { AuthGuard } from './auth/auth.guard';
9
  import { DashboardComponent } from './dashboard/dashboard.component';
 
10
 
11
  const routes: Routes = [
12
  { path: '', component: Homepage },
13
  { path: 'home', component: Homepage },
14
  { path: 'dashboard', component: DashboardComponent },
 
15
  { path: 'analysepage', component: Analysispage, canActivate: [AuthGuard] },
16
  { path: 'chatbot', component: Chatbot },
17
  { path: 'screener', component: Screenerpage },
 
7
  import { Screenerpage } from './screenerpage/screenerpage';
8
  import { AuthGuard } from './auth/auth.guard';
9
  import { DashboardComponent } from './dashboard/dashboard.component';
10
+ import { CommunityComponent } from './community/community.component';
11
 
12
  const routes: Routes = [
13
  { path: '', component: Homepage },
14
  { path: 'home', component: Homepage },
15
  { path: 'dashboard', component: DashboardComponent },
16
+ { path: 'community', component: CommunityComponent },
17
  { path: 'analysepage', component: Analysispage, canActivate: [AuthGuard] },
18
  { path: 'chatbot', component: Chatbot },
19
  { path: 'screener', component: Screenerpage },
src/app/app.html CHANGED
@@ -4,7 +4,7 @@
4
  <h1 class="logo-title">PY-TRADE</h1>
5
  <div style="display: flex; gap: 2vw; margin-left: 47vw;align-self:flex-start;">
6
  <p class="menu-item" routerLink="/dashboard">Markets</p>
7
- <p class="menu-item">Community</p>
8
  <p class="menu-item" routerLink="/chatbot">Trading Assistant</p>
9
  </div>
10
 
 
4
  <h1 class="logo-title">PY-TRADE</h1>
5
  <div style="display: flex; gap: 2vw; margin-left: 47vw;align-self:flex-start;">
6
  <p class="menu-item" routerLink="/dashboard">Markets</p>
7
+ <p class="menu-item" routerLink="/community">Community</p>
8
  <p class="menu-item" routerLink="/chatbot">Trading Assistant</p>
9
  </div>
10
 
src/app/community/community.component.html ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="page">
2
+ <!-- Top Bar -->
3
+ <header class="topbar">
4
+ <h1>Community Forum</h1>
5
+ <span class="pill" style="padding:6px 10px;border-radius:999px;background:#0b121c;border:1px solid var(--border);color:var(--muted);">Beta</span>
6
+ <div class="searchbar">
7
+ <input type="search" placeholder="Search threads, tags, tickers…" />
8
+ <select aria-label="Sort">
9
+ <option>Latest</option>
10
+ <option>Most Active</option>
11
+ <option>Top Voted</option>
12
+ <option>Unanswered</option>
13
+ </select>
14
+ </div>
15
+ <button class="btn" (click)="toggleModal(true)">New Thread</button>
16
+ </header>
17
+
18
+ <!-- Sidebar (unchanged) -->
19
+ <aside class="sidebar">
20
+ <div class="block">
21
+ <h3>CATEGORIES</h3>
22
+ <div class="cat-list">
23
+ <div class="cat active"><span>General Discussion</span><small style="color:var(--muted)">—</small></div>
24
+ <div class="cat"><span>Intraday Trading</span><small style="color:var(--muted)">—</small></div>
25
+ <div class="cat"><span>Swing & Position</span><small style="color:var(--muted)">—</small></div>
26
+ <div class="cat"><span>Options & Futures</span><small style="color:var(--muted)">—</small></div>
27
+ <div class="cat"><span>Fundamental Analysis</span><small style="color:var(--muted)">—</small></div>
28
+ <div class="cat"><span>Brokers & Tools</span><small style="color:var(--muted)">—</small></div>
29
+ <div class="cat"><span>Announcements</span><small style="color:var(--muted)">—</small></div>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="block">
34
+ <h3>POPULAR TAGS</h3>
35
+ <div class="tag-list" style="gap:8px;flex-wrap:wrap;">
36
+ <span class="tag">#NSE</span>
37
+ <span class="tag">#BANKNIFTY</span>
38
+ <span class="tag">#Earnings</span>
39
+ <span class="tag">#Result</span>
40
+ <span class="tag">#IPO</span>
41
+ <span class="tag">#Strategy</span>
42
+ <span class="tag">#Beginner</span>
43
+ </div>
44
+ </div>
45
+ </aside>
46
+
47
+ <!-- Main Content -->
48
+ <main class="content">
49
+ <!-- Thread List -->
50
+ <section class="panel">
51
+ <div class="toolbar">
52
+ <span class="pill">General</span>
53
+ <span class="pill">Showing: Latest</span>
54
+ </div>
55
+
56
+ <div class="threads" id="threadList">
57
+ <div *ngIf="loading" class="tiny" style="color:var(--muted)">Loading…</div>
58
+ <div *ngIf="!loading && loadError" class="tiny" style="color:var(--danger)">{{loadError}}</div>
59
+
60
+ <article class="thread" *ngFor="let p of posts" (click)="openThread(p.id)">
61
+ <div class="title">
62
+ {{ p.title || (p.body | slice:0:60) + (p.body.length > 60 ? '…' : '') }}
63
+ </div>
64
+ <div class="meta">
65
+ <span>by <strong>{{ p.userName }}</strong></span>
66
+ <span>• {{ p.createdAt | date:'short' }}</span>
67
+ <div class="chips" *ngIf="p.tags">
68
+ <span class="chip" *ngFor="let t of splitTags(p.tags)">{{ t }}</span>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Body content -->
73
+ <div class="body-text">{{ p.body }}</div>
74
+
75
+ <!-- Actions -->
76
+ <div class="actions" (click)="$event.stopPropagation()">
77
+ <button class="btn ghost" (click)="likePost(p)">👍 Like <span class="count">{{ p.likeCount ?? 0 }}</span></button>
78
+ <button class="btn ghost" (click)="dislikePost(p)">👎 Dislike <span class="count">{{ p.dislikeCount ?? 0 }}</span></button>
79
+ <button class="btn ghost" (click)="toggleReply(p.id)">💬 Reply <span class="count">{{ p.commentCount ?? 0 }}</span></button>
80
+ </div>
81
+
82
+ <!-- Inline reply editor -->
83
+ <div class="reply" *ngIf="isReplyOpen(p.id)" (click)="$event.stopPropagation()">
84
+ <textarea [(ngModel)]="replyDraft[p.id]" placeholder="Write your reply…"></textarea>
85
+ <div style="display:flex; gap:8px; justify-content:flex-end">
86
+ <button class="btn ghost" (click)="toggleReply(p.id)">Cancel</button>
87
+ <button class="btn" [disabled]="!(replyDraft[p.id] || '').trim()" (click)="submitReply(p.id)">Post Reply</button>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="tiny" style="color:var(--muted)">{{ p.category || 'General' }}</div>
92
+ </article>
93
+
94
+ <div *ngIf="!loading && posts.length === 0" class="tiny" style="color:var(--muted)">No posts yet.</div>
95
+ </div>
96
+ </section>
97
+
98
+
99
+ </main>
100
+ </div>
101
+
102
+ <!-- New Thread Modal -->
103
+ <div id="modal"
104
+ [style.display]="showModal ? 'block' : 'none'"
105
+ style="position:fixed; inset:0; z-index:1000; backdrop-filter: blur(6px); background:rgba(4,8,12,.35)">
106
+ <div style="max-width:720px; margin:60px auto; background:rgba(17,23,35,.98); border:1px solid var(--border); border-radius:16px; padding:16px; box-shadow:0 20px 60px rgba(0,0,0,.6);">
107
+ <div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
108
+ <h2 style="margin:0; font-size:18px;">Create New Thread</h2>
109
+ </div>
110
+ <div style="display:grid; gap:10px;">
111
+ <input [(ngModel)]="title" placeholder="Title"
112
+ style="padding:10px 12px; border-radius:12px; background:#0b121c; border:1px solid var(--border); color:var(--text)" />
113
+ <div style="display:flex; gap:8px; flex-wrap:wrap">
114
+ <select [(ngModel)]="category"
115
+ style="padding:10px 12px; border-radius:12px; background:#0b121c; border:1px solid var(--border); color:var(--text)">
116
+ <option>General Discussion</option>
117
+ <option>Intraday Trading</option>
118
+ <option>Swing & Position</option>
119
+ <option>Options & Futures</option>
120
+ <option>Fundamental Analysis</option>
121
+ </select>
122
+ <input [(ngModel)]="tags" placeholder="#tags (comma separated)"
123
+ style="flex:1; padding:10px 12px; border-radius:12px; background:#0b121c; border:1px solid var(--border); color:var(--text)" />
124
+ </div>
125
+ <textarea [(ngModel)]="body" placeholder="Write your post…" style="min-height:180px"></textarea>
126
+ <div style="display:flex; gap:8px; justify-content:flex-end">
127
+ <button class="btn ghost" (click)="toggleModal(false)">Cancel</button>
128
+ <button class="btn" [disabled]="!body.trim()" (click)="publish()">Publish</button>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
src/app/community/community.component.scss ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b0f14;
3
+ --card: #121a2f;
4
+ --muted: #8aa0b5;
5
+ --text: #e8f0f7;
6
+ --accent: #23d18b;
7
+ --accent-2: #5aa7ff;
8
+ --danger: #ff5d5d;
9
+ --warning: #ffd166;
10
+ --border: #1e2a3a;
11
+ --chip: #152234;
12
+ }
13
+
14
+ * { box-sizing: border-box }
15
+
16
+ body {
17
+ margin: 0;
18
+ font: 14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial;
19
+ color: var(--text);
20
+ background: linear-gradient(180deg,#0b0f14 0%, #0b0f14 60%, #0e1420 100%);
21
+ }
22
+
23
+ .page {
24
+ display: grid;
25
+ grid-template-columns: 260px 1fr;
26
+ gap: 16px;
27
+ /* max-width: 1200px;
28
+ margin: 0 auto;*/
29
+ padding: 3vw;
30
+ padding-top:10vw;
31
+ }
32
+
33
+ header.topbar {
34
+ grid-column: 1 / -1;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ background: var(--card);
39
+ border: 1px solid var(--border);
40
+ padding: 12px 16px;
41
+ border-radius: 14px;
42
+ position: sticky;
43
+ top: 0;
44
+ z-index: 5;
45
+ }
46
+
47
+ header.topbar h1 {
48
+ font-size: 18px;
49
+ margin: 0 8px 0 0;
50
+ letter-spacing: .2px;
51
+ }
52
+
53
+ .searchbar {
54
+ display: flex;
55
+ gap: 8px;
56
+ align-items: center;
57
+ margin-left: auto;
58
+ width: 50%;
59
+ }
60
+
61
+ .searchbar input[type="search"], .searchbar select {
62
+ width: 100%;
63
+ padding: 10px 12px;
64
+ background: #0b121c;
65
+ border: 1px solid var(--border);
66
+ border-radius: 12px;
67
+ color: var(--text);
68
+ outline: none;
69
+ }
70
+
71
+ .searchbar select { width: auto }
72
+
73
+ .btn {
74
+ padding: 10px 14px;
75
+ border-radius: 12px;
76
+ border: 1px solid transparent;
77
+ background: var(--accent);
78
+ color: #032314;
79
+ font-weight: 600;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .btn.ghost { background: transparent; color: var(--text); border-color: var(--border) }
84
+ .btn.warn { background: var(--warning); color: #2a2300 }
85
+
86
+ aside.sidebar {
87
+ background: var(--card);
88
+ border: 1px solid var(--border);
89
+ border-radius: 14px;
90
+ padding: 12px;
91
+ position: sticky;
92
+ top: 64px;
93
+ height: max-content;
94
+ }
95
+
96
+ .block { margin-bottom: 16px }
97
+ .block h3 { margin: 0 0 8px; font-size: 13px; color: var(--muted); letter-spacing: .5px }
98
+
99
+ .cat-list, .tag-list, .ticker-list {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 6px;
103
+ }
104
+
105
+ .cat {
106
+ display: flex;
107
+ justify-content: space-between;
108
+ align-items: center;
109
+ padding: 8px 10px;
110
+ border-radius: 10px;
111
+ background: #0b121c;
112
+ border: 1px solid var(--border);
113
+ cursor: pointer;
114
+ }
115
+
116
+ .cat.active { outline: 2px solid var(--accent-2) }
117
+
118
+ .tag-list .tag, .ticker {
119
+ display: inline-flex;
120
+ align-items: center;
121
+ gap: 6px;
122
+ padding: 6px 10px;
123
+ border-radius: 999px;
124
+ background: var(--chip);
125
+ color: #cfe3ff;
126
+ width: max-content;
127
+ border: 1px solid var(--border);
128
+ cursor: pointer;
129
+ }
130
+
131
+ .ticker small { color: var(--muted) }
132
+
133
+ main.content { display: grid; gap: 16px; }
134
+ .panel {
135
+ background: var(--card);
136
+ border: 1px solid var(--border);
137
+ border-radius: 14px;
138
+ padding: 12px;
139
+ }
140
+
141
+ .toolbar {
142
+ display: flex;
143
+ gap: 8px;
144
+ flex-wrap: wrap;
145
+ align-items: center;
146
+ margin-bottom: 8px;
147
+ }
148
+ .toolbar .pill {
149
+ padding: 6px 10px;
150
+ border-radius: 999px;
151
+ background: #0b121c;
152
+ border: 1px solid var(--border);
153
+ color: var(--muted)
154
+ }
155
+
156
+ .threads {
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: 10px;
160
+ /* max-height: 480px;*/
161
+ overflow: auto;
162
+ padding-right: 4px;
163
+ }
164
+
165
+ .thread {
166
+ padding: 12px;
167
+ border-radius: 12px;
168
+ background: #0b121c;
169
+ border: 1px solid var(--border);
170
+ display: grid;
171
+ gap: 6px;
172
+ cursor: pointer;
173
+ }
174
+ .thread:hover { border-color: #28456b }
175
+ .thread .title { font-weight: 700 }
176
+
177
+ .meta {
178
+ display: flex;
179
+ gap: 10px;
180
+ flex-wrap: wrap;
181
+ align-items: center;
182
+ color: var(--muted);
183
+ font-size: 12px;
184
+ }
185
+
186
+ .chips { display: flex; gap: 6px; flex-wrap: wrap }
187
+ .chip {
188
+ padding: 4px 8px;
189
+ border-radius: 999px;
190
+ background: #0f1a29;
191
+ border: 1px solid var(--border);
192
+ font-size: 12px
193
+ }
194
+ .chip.green { background: #0f2a1f; color: #9af6c9; border-color: #1c3c2d }
195
+
196
+ .stats { margin-left: auto; display: flex; gap: 8px }
197
+
198
+ .view { display: grid; gap: 8px; }
199
+
200
+ .post {
201
+ background: #0b121c;
202
+ border: 1px solid var(--border);
203
+ border-radius: 12px;
204
+ padding: 10px;
205
+ }
206
+ .post .head { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 12px }
207
+ .post .body { margin-top: 6px }
208
+
209
+ .editor {
210
+ display: grid;
211
+ gap: 8px;
212
+ background: #0b121c;
213
+ border: 1px dashed #2a3a52;
214
+ padding: 12px;
215
+ border-radius: 12px;
216
+ }
217
+
218
+ textarea {
219
+ width: 100%;
220
+ min-height: 120px;
221
+ resize: vertical;
222
+ padding: 10px 12px;
223
+ background: #09111a;
224
+ color: var(--text);
225
+ border: 1px solid var(--border);
226
+ border-radius: 10px;
227
+ outline: none;
228
+ }
229
+
230
+ .tiny { font-size: 12px; color: var(--muted); }
231
+ .note { padding: 8px; background: #101a28; border: 1px solid #1b2b43; border-radius: 10px; color: #b9d7ff }
232
+
233
+ /* Modal safety overrides */
234
+ #modal { z-index: 1000 !important; }
235
+ #modal > div {
236
+ background: rgba(17,23,35,.98); /* slightly brighter than pure black for clarity */
237
+ box-shadow: 0 20px 60px rgba(0,0,0,.6);
238
+ }
239
+
240
+ /* responsive */
241
+ @media (max-width: 960px) {
242
+ .page { grid-template-columns: 1fr }
243
+ aside.sidebar { position: static }
244
+ header.topbar { position: static }
245
+ }
246
+
247
+ .thread .body-text {
248
+ white-space: pre-wrap;
249
+ color: var(--text);
250
+ font-size: 13px;
251
+ }
252
+
253
+ .thread .actions {
254
+ display: flex;
255
+ gap: 8px;
256
+ margin-top: 6px;
257
+ }
258
+
259
+ .thread .actions .btn.ghost {
260
+ border-color: var(--border);
261
+ background: #0b121c;
262
+ color: var(--muted);
263
+ padding: 6px 10px;
264
+ }
265
+
266
+ .thread .actions .count {
267
+ opacity: .8;
268
+ margin-left: 4px;
269
+ }
270
+
271
+ .thread .reply {
272
+ margin-top: 8px;
273
+ background: #0b121c;
274
+ border: 1px dashed #2a3a52;
275
+ padding: 8px;
276
+ border-radius: 10px;
277
+ }
278
+
279
+ .thread .reply textarea {
280
+ min-height: 80px;
281
+ }
src/app/community/community.component.ts ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, ViewEncapsulation, OnInit } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
4
+ import { AngularEditorModule } from '@kolkov/angular-editor';
5
+ import { AuthService } from '../auth/auth.service';
6
+ import { firstValueFrom } from 'rxjs';
7
+
8
+ type Post = {
9
+ id: number;
10
+ userId: number;
11
+ userName: string;
12
+ title?: string;
13
+ category?: string;
14
+ tags?: string;
15
+ body: string;
16
+ createdAt: string;
17
+ likeCount?: number;
18
+ dislikeCount?: number;
19
+ commentCount?: number;
20
+ };
21
+
22
+ @Component({
23
+ selector: 'app-community',
24
+ standalone: true,
25
+ imports: [CommonModule, FormsModule, AngularEditorModule, ReactiveFormsModule],
26
+ templateUrl: './community.component.html',
27
+ styleUrls: ['./community.component.scss'],
28
+ encapsulation: ViewEncapsulation.None
29
+ })
30
+ export class CommunityComponent implements OnInit {
31
+ showModal = false;
32
+
33
+ // modal bound fields
34
+ title = '';
35
+ category = 'General Discussion';
36
+ tags = '';
37
+ tickers = '';
38
+ body = '';
39
+
40
+ posts: Post[] = [];
41
+ loading = false;
42
+ loadError = '';
43
+
44
+ // reply state per post
45
+ replyDraft: Record<number, string> = {};
46
+ private replyOpen = new Set<number>();
47
+
48
+ private readonly baseUrl =
49
+ location.hostname.endsWith('hf.space')
50
+ ? 'https://pykara-pytrade-backend.hf.space'
51
+ : 'http://127.0.0.1:5000';
52
+
53
+ constructor(private auth: AuthService) {}
54
+
55
+ ngOnInit(): void {
56
+ this.loadPosts();
57
+ }
58
+
59
+ async loadPosts() {
60
+ this.loading = true;
61
+ this.loadError = '';
62
+ try {
63
+ const res = await fetch(`${this.baseUrl}/posts?limit=50&offset=0`, {
64
+ method: 'GET',
65
+ credentials: 'omit',
66
+ });
67
+ if (!res.ok) {
68
+ const err = await res.json().catch(() => ({}));
69
+ throw new Error(err.error || err.message || `Failed (${res.status})`);
70
+ }
71
+ const data = await res.json();
72
+ const list = (data?.results as Post[]) ?? [];
73
+ // Ensure counts exist for UI
74
+ this.posts = list.map(p => ({
75
+ likeCount: 0,
76
+ dislikeCount: 0,
77
+ commentCount: 0,
78
+ ...p
79
+ }));
80
+ } catch (e: any) {
81
+ console.error('Load posts failed:', e);
82
+ this.loadError = e?.message || 'Failed to load posts';
83
+ } finally {
84
+ this.loading = false;
85
+ }
86
+ }
87
+
88
+ splitTags(tags?: string): string[] {
89
+ return (tags ?? '')
90
+ .split(',')
91
+ .map(t => t.trim())
92
+ .filter(t => t.length > 0);
93
+ }
94
+
95
+ toggleModal(show: boolean) {
96
+ this.showModal = show;
97
+ document.body.style.overflow = show ? 'hidden' : '';
98
+ }
99
+
100
+ openThread(id: any) {
101
+ const view = document.getElementById('threadView');
102
+ if (view) view.scrollIntoView({ behavior: 'smooth', block: 'start' });
103
+ }
104
+
105
+ backToList() {
106
+ const list = document.getElementById('threadList');
107
+ if (list) list.scrollIntoView({ behavior: 'smooth', block: 'start' });
108
+ }
109
+
110
+ private async ensureAccessToken(): Promise<string | null> {
111
+ let token = this.auth.getAccessToken();
112
+ if (!token) return null;
113
+ if (this.auth.tokenExpired()) {
114
+ try {
115
+ token = await firstValueFrom(this.auth.refreshAccessToken());
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ return token || null;
121
+ }
122
+
123
+ private parseJwt(token: string): any | null {
124
+ try {
125
+ const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
126
+ const json = decodeURIComponent(atob(base64).split('').map(c => {
127
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
128
+ }).join(''));
129
+ return JSON.parse(json);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ private getUserContextFromToken(token: string): { userId?: number; userName?: string } {
136
+ const payload = this.parseJwt(token);
137
+ if (!payload) return {};
138
+ const userId = payload.sub ? Number(payload.sub) : undefined;
139
+ const userName =
140
+ payload.name ||
141
+ payload.preferred_username ||
142
+ [payload.given_name, payload.family_name].filter(Boolean).join(' ') ||
143
+ undefined;
144
+ return { userId, userName };
145
+ }
146
+
147
+ isReplyOpen(postId: number): boolean {
148
+ return this.replyOpen.has(postId);
149
+ }
150
+
151
+ toggleReply(postId: number) {
152
+ if (this.replyOpen.has(postId)) {
153
+ this.replyOpen.delete(postId);
154
+ } else {
155
+ this.replyOpen.add(postId);
156
+ }
157
+ }
158
+
159
+ async likePost(p: Post) {
160
+ // optimistic UI
161
+ p.likeCount = (p.likeCount ?? 0) + 1;
162
+ const token = await this.ensureAccessToken();
163
+ if (!token) return; // if not signed-in, keep optimistic or revert if you prefer
164
+
165
+ try {
166
+ await fetch(`${this.baseUrl}/posts/${p.id}/like`, {
167
+ method: 'POST',
168
+ credentials: 'omit',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ 'Authorization': `Bearer ${token}`
172
+ }
173
+ });
174
+ } catch {
175
+ // optionally revert UI on failure
176
+ p.likeCount = Math.max(0, (p.likeCount ?? 1) - 1);
177
+ }
178
+ }
179
+
180
+ async dislikePost(p: Post) {
181
+ p.dislikeCount = (p.dislikeCount ?? 0) + 1;
182
+ const token = await this.ensureAccessToken();
183
+ if (!token) return;
184
+
185
+ try {
186
+ await fetch(`${this.baseUrl}/posts/${p.id}/dislike`, {
187
+ method: 'POST',
188
+ credentials: 'omit',
189
+ headers: {
190
+ 'Content-Type': 'application/json',
191
+ 'Authorization': `Bearer ${token}`
192
+ }
193
+ });
194
+ } catch {
195
+ p.dislikeCount = Math.max(0, (p.dislikeCount ?? 1) - 1);
196
+ }
197
+ }
198
+
199
+ async submitReply(postId: number) {
200
+ const draft = (this.replyDraft[postId] || '').trim();
201
+ if (!draft) return;
202
+
203
+ const token = await this.ensureAccessToken();
204
+ if (!token) {
205
+ alert('Please sign in to reply.');
206
+ return;
207
+ }
208
+
209
+ try {
210
+ const res = await fetch(`${this.baseUrl}/posts/${postId}/comments`, {
211
+ method: 'POST',
212
+ credentials: 'omit',
213
+ headers: {
214
+ 'Content-Type': 'application/json',
215
+ 'Authorization': `Bearer ${token}`
216
+ },
217
+ body: JSON.stringify({ body: draft })
218
+ });
219
+ if (!res.ok) {
220
+ const err = await res.json().catch(() => ({}));
221
+ throw new Error(err.error || err.message || `Failed (${res.status})`);
222
+ }
223
+ // success: clear draft and bump counter
224
+ delete this.replyDraft[postId];
225
+ const post = this.posts.find(x => x.id === postId);
226
+ if (post) post.commentCount = (post.commentCount ?? 0) + 1;
227
+ this.replyOpen.delete(postId);
228
+ } catch (e: any) {
229
+ console.error('Reply failed', e);
230
+ alert(e?.message || 'Failed to post reply');
231
+ }
232
+ }
233
+
234
+ async publish() {
235
+ if (!this.body.trim()) {
236
+ alert('Post body is required');
237
+ return;
238
+ }
239
+
240
+ const token = await this.ensureAccessToken();
241
+ if (!token) {
242
+ alert('Please sign in to publish.');
243
+ return;
244
+ }
245
+
246
+ const { userId, userName } = this.getUserContextFromToken(token);
247
+
248
+ try {
249
+ const res = await fetch(`${this.baseUrl}/posts`, {
250
+ method: 'POST',
251
+ credentials: 'omit',
252
+ headers: {
253
+ 'Content-Type': 'application/json',
254
+ 'Authorization': `Bearer ${token}`
255
+ },
256
+ body: JSON.stringify({
257
+ userId, // optional; backend may ignore and derive from JWT/DB
258
+ userName, // optional; backend may ignore and derive from DB
259
+ title: this.title,
260
+ category: this.category,
261
+ tags: this.tags,
262
+ body: this.body
263
+ })
264
+ });
265
+
266
+ if (res.status === 401) {
267
+ alert('Session expired. Please sign in again.');
268
+ return;
269
+ }
270
+ if (!res.ok) {
271
+ const err = await res.json().catch(() => ({}));
272
+ throw new Error(err.error || err.message || `Failed (${res.status})`);
273
+ }
274
+
275
+ // Clear and reload
276
+ this.title = '';
277
+ this.category = 'General Discussion';
278
+ this.tags = '';
279
+ this.tickers = '';
280
+ this.body = '';
281
+ this.toggleModal(false);
282
+ await this.loadPosts();
283
+ } catch (e: any) {
284
+ console.error('Publish error:', e);
285
+ alert(e?.message || 'Failed to publish');
286
+ }
287
+ }
288
+ }