Skip to content

Commit 2ee8951

Browse files
Uttam-Mahataclaude
andcommitted
feat: implement 7 missing UI features + detailed contest analytics
Quick additions to existing components: - CTFd Export: button in admin challenges header → ctfd-export.json download - Scoreboard Export: JSON + CSV buttons in collaborator contest teams tab - Team Transfer Leadership: dropdown + Transfer button in members tab (leader only) - Writeup Upvotes: thumbs-up + live count per writeup in challenge-detail - Challenge Rating: 5-star hover widget in challenge-detail, persisted via API New pages: - Submission History: /submissions page with correct/wrong status, points, timestamp (getMySubmissions() added to ChallengeService → GET /submissions/my) Detailed contest analytics tab (collaborator): - 5 stats cards: Teams, Challenges, Submissions, Solves, Solve Rate - 4 Chart.js charts: Challenge Solve Counts (bar), Category breakdown (doughnut), Difficulty breakdown (doughnut), Team Leaderboard (bar) - Challenge breakdown table with difficulty badges and solve-rate progress bars - Analytics accessible via desktop sidebar + mobile dropdown in collaborator dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 68b8aee commit 2ee8951

13 files changed

Lines changed: 554 additions & 14 deletions

File tree

frontend/src/app/app.routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export const routes: Routes = [
107107
{ path: 'contests/:contest_id/challenges', loadComponent: () => import('./components/contest-challenges/contest-challenges').then(m => m.ContestChallengesComponent), canActivate: [authGuard] },
108108
{ path: 'contests/:contest_id/challenges/:id', loadComponent: () => import('./components/challenge-detail/challenge-detail').then(m => m.ChallengeDetailComponent), canActivate: [authGuard] },
109109

110+
{ path: 'submissions', loadComponent: () => import('./components/submissions/submissions').then(m => m.SubmissionsComponent), canActivate: [authGuard] },
110111
{ path: 'activity', loadComponent: () => import('./components/activity-dashboard/activity-dashboard').then(m => m.ActivityDashboardComponent), canActivate: [authGuard] },
111112
{ path: 'notifications', loadComponent: () => import('./components/notifications/notifications').then(m => m.NotificationsComponent), canActivate: [authGuard] },
112113
{

frontend/src/app/components/admin-dashboard/tabs/admin-challenges/admin-challenges.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<!-- View Switcher Tabs -->
2-
<nav class="flex items-center gap-1 mb-5 p-1 rounded-xl w-fit" style="background: rgba(15,23,42,0.7); border: 1px solid rgba(255,255,255,0.06);">
2+
<div class="flex items-center justify-between gap-3 mb-5 flex-wrap">
3+
<nav class="flex items-center gap-1 p-1 rounded-xl w-fit" style="background: rgba(15,23,42,0.7); border: 1px solid rgba(255,255,255,0.06);">
34
<button type="button" (click)="switchView('manage')"
45
class="px-4 py-2 text-sm font-medium rounded-lg transition-all duration-150 flex items-center gap-2"
56
[style]="activeView === 'manage' ? 'background: rgba(139,92,246,0.15); color: #a78bfa;' : 'color: #64748b;'">
@@ -14,6 +15,14 @@
1415
{{ isEditMode ? 'Edit Challenge' : 'New Challenge' }}
1516
</button>
1617
</nav>
18+
<button type="button" (click)="exportCTFd()" [disabled]="isExportingCTFd"
19+
class="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl transition-colors disabled:opacity-50"
20+
style="background: rgba(245,158,11,0.1); color: #fbbf24; border: 1px solid rgba(245,158,11,0.2);">
21+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
22+
<span *ngIf="!isExportingCTFd">CTFd Export</span>
23+
<span *ngIf="isExportingCTFd">Exporting…</span>
24+
</button>
25+
</div>
1726

1827
<!-- ===== CREATE / EDIT VIEW ===== -->
1928
<div *ngIf="activeView === 'create'" class="w-full">

frontend/src/app/components/admin-dashboard/tabs/admin-challenges/admin-challenges.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular';
55
import Showdown from 'showdown';
66
import { ChallengeService, ChallengeAdmin, ChallengeRequest } from '../../../../services/challenge';
77
import { BulkChallengeService } from '../../../../services/bulk-challenge';
8+
import { CollaboratorService } from '../../../../services/collaborator';
89
import { ThemeService } from '../../../../services/theme';
910
import { ConfirmationModalService } from '../../../../services/confirmation-modal.service';
1011
import { AdminStateService } from '../../../../services/admin-state';
@@ -25,6 +26,7 @@ export class AdminChallengesComponent implements OnInit {
2526
private destroyRef = inject(DestroyRef);
2627
private challengeService = inject(ChallengeService);
2728
private bulkChallengeService = inject(BulkChallengeService);
29+
private collaboratorService = inject(CollaboratorService);
2830
private themeService = inject(ThemeService);
2931
private confirmationModalService = inject(ConfirmationModalService);
3032
private cdr = inject(ChangeDetectorRef);
@@ -42,6 +44,30 @@ export class AdminChallengesComponent implements OnInit {
4244
flagVerifyStatus: 'none' | 'match' | 'no-match' = 'none';
4345
previewChallenge: ChallengeAdmin | null = null;
4446

47+
// CTFd Export
48+
isExportingCTFd = false;
49+
50+
exportCTFd(): void {
51+
this.isExportingCTFd = true;
52+
this.collaboratorService.exportCTFd().subscribe({
53+
next: (data) => {
54+
this.isExportingCTFd = false;
55+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
56+
const url = window.URL.createObjectURL(blob);
57+
const a = document.createElement('a');
58+
a.href = url;
59+
a.download = 'ctfd-export.json';
60+
a.click();
61+
window.URL.revokeObjectURL(url);
62+
this.adminState.showMessage('CTFd export downloaded', 'success');
63+
},
64+
error: () => {
65+
this.isExportingCTFd = false;
66+
this.adminState.showMessage('CTFd export failed', 'error');
67+
}
68+
});
69+
}
70+
4571
// File upload state
4672
isUploadingFile = false;
4773
uploadFileError = '';

frontend/src/app/components/challenge-detail/challenge-detail.html

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ <h3 class="text-sm font-semibold text-slate-900 dark:text-white mb-3 flex items-
8282
<p class="text-slate-500 dark:text-slate-400 text-sm mt-2">
8383
Max: {{ challenge.max_points }} pts • Points decrease as more teams solve
8484
</p>
85+
86+
<!-- Challenge Rating -->
87+
<div *ngIf="challengeRating !== null" class="mt-3 flex items-center gap-3 flex-wrap">
88+
<div class="flex items-center gap-0.5">
89+
<button *ngFor="let star of [1,2,3,4,5]" type="button"
90+
(mouseenter)="hoverRating = star" (mouseleave)="hoverRating = 0"
91+
(click)="submitRating(star)" [disabled]="isSubmittingRating"
92+
class="text-xl leading-none transition-colors disabled:opacity-50 focus:outline-none"
93+
[class.text-amber-400]="star <= (hoverRating || challengeRating!.user_rating || 0)"
94+
[class.text-slate-600]="star > (hoverRating || challengeRating!.user_rating || 0)"></button>
95+
</div>
96+
<span class="text-xs text-slate-500">
97+
{{ challengeRating.average_rating | number:'1.1-1' }} / 5
98+
({{ challengeRating.total_ratings }} {{ challengeRating.total_ratings === 1 ? 'rating' : 'ratings' }})
99+
</span>
100+
</div>
85101
</div>
86102

87103
<!-- Description -->
@@ -225,7 +241,17 @@ <h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-cente
225241
<div *ngFor="let writeup of writeups" class="bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
226242
<div class="flex items-center justify-between mb-3">
227243
<span class="text-sm font-medium text-slate-900 dark:text-white">{{ writeup.username }}</span>
228-
<span class="text-xs text-slate-500 dark:text-slate-400">{{ writeup.created_at | date:'short' }}</span>
244+
<div class="flex items-center gap-3">
245+
<button type="button" (click)="toggleUpvote(writeup.id)"
246+
class="flex items-center gap-1 text-xs transition-colors focus:outline-none"
247+
[class.text-red-400]="writeupVotes.get(writeup.id)?.upvoted"
248+
[class.text-slate-400]="!writeupVotes.get(writeup.id)?.upvoted"
249+
[class.hover:text-red-400]="!writeupVotes.get(writeup.id)?.upvoted">
250+
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 9V5l-7 7 7 7v-4.1c5 .5 8.5 2.1 11 5.1-1-5-4-10-11-11z"/></svg>
251+
{{ writeupVotes.get(writeup.id)?.count ?? 0 }}
252+
</button>
253+
<span class="text-xs text-slate-500 dark:text-slate-400">{{ writeup.created_at | date:'short' }}</span>
254+
</div>
229255
</div>
230256
<div class="prose prose-sm prose-slate dark:prose-invert max-w-none writeup-content prose-headings:text-slate-900 dark:prose-headings:text-white
231257
prose-p:text-slate-700 dark:prose-p:text-slate-300

frontend/src/app/components/challenge-detail/challenge-detail.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ export class ChallengeDetailComponent implements OnInit, OnDestroy, AfterViewChe
7070
writeupEditorContent = '';
7171
writeupEditorConfig: any = {};
7272
showWriteupEditor = false;
73+
74+
// Writeup upvotes: writeupId -> { upvoted, count }
75+
writeupVotes = new Map<string, { upvoted: boolean; count: number }>();
76+
77+
// Challenge rating
78+
challengeRating: { average_rating: number; total_ratings: number; user_rating?: number } | null = null;
79+
hoverRating = 0;
80+
isSubmittingRating = false;
7381

7482
// Solves
7583
solves: SolveEntry[] = [];
@@ -315,6 +323,7 @@ export class ChallengeDetailComponent implements OnInit, OnDestroy, AfterViewChe
315323
this.needsHighlight = true;
316324
this.loadHints();
317325
this.loadWriteups();
326+
this.loadChallengeRating();
318327
},
319328
error: (err) => console.error('Error loading challenge:', err)
320329
});
@@ -367,17 +376,58 @@ export class ChallengeDetailComponent implements OnInit, OnDestroy, AfterViewChe
367376
const format = writeup.content_format || 'markdown';
368377
return {
369378
...writeup,
370-
renderedContent: format === 'html'
371-
? writeup.content
379+
renderedContent: format === 'html'
380+
? writeup.content
372381
: this.markdownConverter.makeHtml(writeup.content || '')
373382
};
374383
});
375384
this.needsHighlight = true;
385+
// Seed upvote counts from writeup data, load vote status for each
386+
this.writeups.forEach(w => {
387+
this.writeupVotes.set(w.id, { upvoted: false, count: w.upvote_count ?? 0 });
388+
this.challengeService.getWriteupVoteStatus(w.id).subscribe({
389+
next: (s) => {
390+
const cur = this.writeupVotes.get(w.id) ?? { upvoted: false, count: w.upvote_count ?? 0 };
391+
this.writeupVotes.set(w.id, { ...cur, upvoted: s.upvoted });
392+
},
393+
error: () => {}
394+
});
395+
});
376396
},
377397
error: () => this.writeups = []
378398
});
379399
}
380400

401+
toggleUpvote(writeupId: string): void {
402+
const cur = this.writeupVotes.get(writeupId) ?? { upvoted: false, count: 0 };
403+
// Optimistic update
404+
const next = { upvoted: !cur.upvoted, count: cur.upvoted ? cur.count - 1 : cur.count + 1 };
405+
this.writeupVotes.set(writeupId, next);
406+
this.challengeService.toggleWriteupUpvote(writeupId).subscribe({
407+
error: () => this.writeupVotes.set(writeupId, cur) // revert on error
408+
});
409+
}
410+
411+
loadChallengeRating(): void {
412+
if (!this.challenge) return;
413+
this.challengeService.getChallengeRating(this.challenge.id).subscribe({
414+
next: (r) => { this.challengeRating = r; },
415+
error: () => { this.challengeRating = null; }
416+
});
417+
}
418+
419+
submitRating(stars: number): void {
420+
if (!this.challenge || this.isSubmittingRating) return;
421+
this.isSubmittingRating = true;
422+
this.challengeService.rateChallenge(this.challenge.id, stars).subscribe({
423+
next: () => {
424+
this.isSubmittingRating = false;
425+
this.loadChallengeRating();
426+
},
427+
error: () => { this.isSubmittingRating = false; }
428+
});
429+
}
430+
381431
submitWriteup(): void {
382432
if (!this.challenge || this.isSubmittingWriteup || !this.writeupEditorContent.trim()) return;
383433

frontend/src/app/components/collaborator-dashboard/collaborator-dashboard.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ <h2 class="text-xs font-bold text-white leading-none">Collaborator</h2>
6565
{section:'rounds', label:'Rounds', icon:'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'},
6666
{section:'challenges', label:'Challenges', icon:'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01'},
6767
{section:'teams', label:'Teams', icon:'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z'},
68-
{section:'collaborators', label:'Collaborators', icon:'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'}
68+
{section:'collaborators', label:'Collaborators', icon:'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'},
69+
{section:'analytics', label:'Analytics', icon:'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'}
6970
]">
7071
<a [routerLink]="['/collaborator/contests', contestId, item.section]"
7172
[title]="!sidebarOpen ? item.label : ''"
@@ -120,6 +121,7 @@ <h1 class="text-sm font-bold text-white">{{ isInContest ? 'Manage Contest' : 'Co
120121
<a [routerLink]="['/collaborator/contests', contestId, 'challenges']" [class.text-emerald-400]="currentSection === 'challenges'" class="flex items-center gap-2.5 px-4 py-2 text-sm text-slate-500 hover:text-slate-200 transition-colors">Challenges</a>
121122
<a [routerLink]="['/collaborator/contests', contestId, 'teams']" [class.text-emerald-400]="currentSection === 'teams'" class="flex items-center gap-2.5 px-4 py-2 text-sm text-slate-500 hover:text-slate-200 transition-colors">Teams</a>
122123
<a [routerLink]="['/collaborator/contests', contestId, 'collaborators']" [class.text-emerald-400]="currentSection === 'collaborators'" class="flex items-center gap-2.5 px-4 py-2 text-sm text-slate-500 hover:text-slate-200 transition-colors">Collaborators</a>
124+
<a [routerLink]="['/collaborator/contests', contestId, 'analytics']" [class.text-emerald-400]="currentSection === 'analytics'" class="flex items-center gap-2.5 px-4 py-2 text-sm text-slate-500 hover:text-slate-200 transition-colors">Analytics</a>
123125
</ng-container>
124126
</div>
125127
</header>

0 commit comments

Comments
 (0)