generated from nhcarrigan/template
feat: implement comprehensive achievement system with 62 achievements
Adds a complete achievement system with gamification features across all user interactions. **Database & Types:** - Add UserAchievement model to track user progress and earned achievements - Add achievement-related fields to User model (achievementPoints, currentStreak, lastStreakCheck) - Add ACHIEVEMENT_UNLOCKED audit action type - Define 62 achievements as TypeScript constants across 5 categories **Achievement Categories:** - Suggestions (15): First suggestion through ultimate curator milestones - Likes (12): First like through legendary fan milestones - Comments (12): First comment through review legend milestones - Engagement (15): Login streaks and total activity tracking - Reports (8): Valid reports and accuracy tracking **Backend Implementation:** - Create AchievementService with comprehensive checking logic - Add achievement route with 6 API endpoints - Integrate achievement checking into all user interaction points: - Suggestions (create + accept) - Likes (toggle) - Comments (all 6 media types) - Login streaks - Reports (profile + comment) - Update UserService to include achievement points in profiles **Frontend Implementation:** - Create AchievementService for API communication - Create achievements page showing all 62 achievements with: - Category filtering (All, Suggestions, Likes, Comments, Engagement, Reports) - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Earned date display for completed achievements - Add "Recent Achievements" section to user profiles - Add "🏆 Achievements" link to header navigation - Only show "View All" link on own profile **Technical Features:** - Real-time achievement checking on user actions - Progress tracking to avoid recalculation - Points system for gamification - Tier-based gradient styling - Leaderboard-ready architecture Resolves #48 Co-Authored-By: Hikari <hikari@nhcarrigan.com>
This commit is contained in:
@@ -61,6 +61,10 @@ export const appRoutes: Route[] = [
|
||||
path: 'settings',
|
||||
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
|
||||
},
|
||||
{
|
||||
path: 'achievements',
|
||||
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AchievementCategory,
|
||||
AchievementProgress,
|
||||
AchievementTier,
|
||||
} from '@library/shared-types';
|
||||
import { AchievementService } from '../../services/achievement.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-achievements',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="achievements-container">
|
||||
<div class="achievements-header">
|
||||
<h1>🏆 Achievements</h1>
|
||||
<p class="subtitle">Track your progress across all achievement categories</p>
|
||||
|
||||
@if (totalPoints() > 0) {
|
||||
<div class="total-points">
|
||||
<span class="points-label">Total Points:</span>
|
||||
<span class="points-value">{{ totalPoints() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
[class.active]="selectedCategory() === null"
|
||||
(click)="selectCategory(null)">
|
||||
All ({{ totalEarned() }}/{{ totalAchievements() }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Suggestion"
|
||||
(click)="selectCategory(AchievementCategory.Suggestion)">
|
||||
📝 Suggestions ({{ getCategoryCount(AchievementCategory.Suggestion) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Like"
|
||||
(click)="selectCategory(AchievementCategory.Like)">
|
||||
❤️ Likes ({{ getCategoryCount(AchievementCategory.Like) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Comment"
|
||||
(click)="selectCategory(AchievementCategory.Comment)">
|
||||
💬 Comments ({{ getCategoryCount(AchievementCategory.Comment) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Engagement"
|
||||
(click)="selectCategory(AchievementCategory.Engagement)">
|
||||
🎯 Engagement ({{ getCategoryCount(AchievementCategory.Engagement) }})
|
||||
</button>
|
||||
<button
|
||||
[class.active]="selectedCategory() === AchievementCategory.Report"
|
||||
(click)="selectCategory(AchievementCategory.Report)">
|
||||
🛡️ Reports ({{ getCategoryCount(AchievementCategory.Report) }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading achievements...</div>
|
||||
} @else if (error()) {
|
||||
<div class="error">{{ error() }}</div>
|
||||
} @else {
|
||||
<div class="achievements-grid">
|
||||
@for (achievement of filteredAchievements(); track achievement.definition.key) {
|
||||
<div
|
||||
class="achievement-card"
|
||||
[class.earned]="achievement.earned"
|
||||
[class.locked]="!achievement.earned"
|
||||
[attr.data-tier]="achievement.definition.tier.toLowerCase()">
|
||||
|
||||
<div class="achievement-icon">
|
||||
{{ achievement.definition.icon }}
|
||||
</div>
|
||||
|
||||
<div class="achievement-content">
|
||||
<h3 class="achievement-title">
|
||||
{{ achievement.definition.title }}
|
||||
@if (achievement.earned) {
|
||||
<span class="earned-check">✓</span>
|
||||
}
|
||||
</h3>
|
||||
|
||||
<p class="achievement-description">
|
||||
{{ achievement.definition.description }}
|
||||
</p>
|
||||
|
||||
<div class="achievement-footer">
|
||||
<span class="achievement-tier" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
|
||||
{{ getTierLabel(achievement.definition.tier) }}
|
||||
</span>
|
||||
<span class="achievement-points">{{ achievement.definition.points }} pts</span>
|
||||
</div>
|
||||
|
||||
@if (!achievement.earned && achievement.progress > 0) {
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" [style.width.%]="achievement.progress"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ achievement.progress }}% complete</div>
|
||||
}
|
||||
|
||||
@if (achievement.earned && achievement.earnedAt) {
|
||||
<div class="earned-date">
|
||||
Earned {{ formatDate(achievement.earnedAt) }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.achievements-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.achievements-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.achievements-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #a0aec0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.total-points {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-buttons button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #2d3748;
|
||||
color: #cbd5e0;
|
||||
border: 2px solid #4a5568;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-buttons button:hover {
|
||||
background: #4a5568;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.filter-buttons button.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.achievement-card {
|
||||
background: #2d3748;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
border: 2px solid #4a5568;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.achievement-card.earned {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.achievement-card.earned:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.achievement-card.locked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.achievement-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.achievement-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.earned-check {
|
||||
color: #48bb78;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.achievement-description {
|
||||
color: #a0aec0;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.achievement-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.achievement-tier {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="bronze"] {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="silver"] {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #a8a8a8 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="gold"] {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="platinum"] {
|
||||
background: linear-gradient(135deg, #e5e4e2 0%, #bdb8af 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-tier[data-tier="diamond"] {
|
||||
background: linear-gradient(135deg, #b9f2ff 0%, #7ec8e3 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #4a5568;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.875rem;
|
||||
color: #a0aec0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.earned-date {
|
||||
font-size: 0.875rem;
|
||||
color: #48bb78;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.2rem;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fc8181;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AchievementsComponent implements OnInit {
|
||||
private readonly achievementService = inject(AchievementService);
|
||||
|
||||
achievements = signal<AchievementProgress[]>([]);
|
||||
selectedCategory = signal<AchievementCategory | null>(null);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Expose AchievementCategory enum for template
|
||||
readonly AchievementCategory = AchievementCategory;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAchievements();
|
||||
}
|
||||
|
||||
private loadAchievements(): void {
|
||||
this.achievementService.getCurrentUserProgress().subscribe({
|
||||
next: (achievements) => {
|
||||
this.achievements.set(achievements);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load achievements');
|
||||
this.loading.set(false);
|
||||
console.error('Error loading achievements:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
filteredAchievements(): AchievementProgress[] {
|
||||
const category = this.selectedCategory();
|
||||
if (!category) {
|
||||
return this.achievements();
|
||||
}
|
||||
return this.achievements().filter(
|
||||
(a) => a.definition.category === category,
|
||||
);
|
||||
}
|
||||
|
||||
selectCategory(category: AchievementCategory | null): void {
|
||||
this.selectedCategory.set(category);
|
||||
}
|
||||
|
||||
totalAchievements(): number {
|
||||
return this.achievements().length;
|
||||
}
|
||||
|
||||
totalEarned(): number {
|
||||
return this.achievements().filter((a) => a.earned).length;
|
||||
}
|
||||
|
||||
totalPoints(): number {
|
||||
return this.achievements()
|
||||
.filter((a) => a.earned)
|
||||
.reduce((sum, a) => sum + a.definition.points, 0);
|
||||
}
|
||||
|
||||
getCategoryCount(category: AchievementCategory): string {
|
||||
const total = this.achievements().filter(
|
||||
(a) => a.definition.category === category,
|
||||
).length;
|
||||
const earned = this.achievements().filter(
|
||||
(a) => a.definition.category === category && a.earned,
|
||||
).length;
|
||||
return `${earned}/${total}`;
|
||||
}
|
||||
|
||||
getTierLabel(tier: AchievementTier): string {
|
||||
return tier;
|
||||
}
|
||||
|
||||
formatDate(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'today';
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return 'yesterday';
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||
}
|
||||
if (diffDays < 365) {
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||
}
|
||||
const years = Math.floor(diffDays / 365);
|
||||
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { ApiService } from '../../services/api.service';
|
||||
<div class="dropdown-menu">
|
||||
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
|
||||
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
|
||||
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
|
||||
@if (!user.isAdmin) {
|
||||
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
|
||||
}
|
||||
|
||||
@@ -5,21 +5,22 @@
|
||||
*/
|
||||
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
|
||||
import { UserService, UserProfileResponse } from '../../services/user.service';
|
||||
import { AchievementService } from '../../services/achievement.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ReportModalComponent } from '../report-modal/report-modal.component';
|
||||
import { PrimaryBadge } from '@library/shared-types';
|
||||
import { PrimaryBadge, AchievementProgress } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FontAwesomeModule, ReportModalComponent],
|
||||
imports: [CommonModule, FontAwesomeModule, ReportModalComponent, RouterModule],
|
||||
template: `
|
||||
<div class="profile-container">
|
||||
@if (loading()) {
|
||||
@@ -151,9 +152,37 @@ import { PrimaryBadge } from '@library/shared-types';
|
||||
<span class="stat-value">{{ profile()!.stats.commentsCount }}</span>
|
||||
<span class="stat-label">Comments</span>
|
||||
</div>
|
||||
@if (profile()!.achievementPoints > 0) {
|
||||
<div class="stat-card achievement-points-card">
|
||||
<span class="stat-value">{{ profile()!.achievementPoints }}</span>
|
||||
<span class="stat-label">🏆 Achievement Points</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (recentAchievements().length > 0) {
|
||||
<div class="achievements-section">
|
||||
<div class="section-header">
|
||||
<h2>Recent Achievements</h2>
|
||||
@if (isOwnProfile()) {
|
||||
<a routerLink="/achievements" class="view-all-link">View All →</a>
|
||||
}
|
||||
</div>
|
||||
<div class="achievements-grid">
|
||||
@for (achievement of recentAchievements(); track achievement.definition.key) {
|
||||
<div class="achievement-badge" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
|
||||
<div class="achievement-icon">{{ achievement.definition.icon }}</div>
|
||||
<div class="achievement-info">
|
||||
<div class="achievement-title">{{ achievement.definition.title }}</div>
|
||||
<div class="achievement-points">{{ achievement.definition.points }} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="footer-section">
|
||||
<p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p>
|
||||
</div>
|
||||
@@ -372,7 +401,7 @@ import { PrimaryBadge } from '@library/shared-types';
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-section h2 {
|
||||
.stats-section h2, .achievements-section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--accent-colour, #9b59b6);
|
||||
font-size: 1.5rem;
|
||||
@@ -417,15 +446,114 @@ import { PrimaryBadge } from '@library/shared-types';
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.achievement-points-card {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.achievements-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
color: var(--accent-colour, #9b59b6);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.view-all-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.achievements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.achievement-badge {
|
||||
background: var(--card-background, #16213e);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="bronze"] {
|
||||
border-color: #cd7f32;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="silver"] {
|
||||
border-color: #c0c0c0;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="gold"] {
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="platinum"] {
|
||||
border-color: #e5e4e2;
|
||||
}
|
||||
|
||||
.achievement-badge[data-tier="diamond"] {
|
||||
border-color: #b9f2ff;
|
||||
}
|
||||
|
||||
.achievement-badge:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.achievement-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.achievement-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-colour, #ffffff);
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.achievement-points {
|
||||
color: var(--text-colour, #ffffff);
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ProfileComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private userService = inject(UserService);
|
||||
private achievementService = inject(AchievementService);
|
||||
private toastService = inject(ToastService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
profile = signal<UserProfileResponse | null>(null);
|
||||
recentAchievements = signal<AchievementProgress[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
reportModalOpen = signal(false);
|
||||
@@ -455,6 +583,26 @@ export class ProfileComponent implements OnInit {
|
||||
next: (profileData: UserProfileResponse) => {
|
||||
this.profile.set(profileData);
|
||||
this.loading.set(false);
|
||||
|
||||
// Load recent achievements
|
||||
this.achievementService.getUserProgress(profileData.id).subscribe({
|
||||
next: (achievements) => {
|
||||
// Get earned achievements sorted by earned date, take top 6
|
||||
const earned = achievements
|
||||
.filter(a => a.earned && a.earnedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = a.earnedAt ? new Date(a.earnedAt).getTime() : 0;
|
||||
const dateB = b.earnedAt ? new Date(b.earnedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
.slice(0, 6);
|
||||
this.recentAchievements.set(earned);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading achievements:', err);
|
||||
// Don't show error toast for achievements failure
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err: Error) => {
|
||||
console.error('Error loading profile:', err);
|
||||
@@ -489,6 +637,20 @@ export class ProfileComponent implements OnInit {
|
||||
return currentUser.id !== profileData.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether viewing your own profile.
|
||||
*/
|
||||
isOwnProfile(): boolean {
|
||||
const currentUser = this.authService.user();
|
||||
const profileData = this.profile();
|
||||
|
||||
if (!currentUser || !profileData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentUser.id === profileData.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the report modal.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
AchievementDefinition,
|
||||
AchievementProgress,
|
||||
UserAchievementSummary,
|
||||
} from '@library/shared-types';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AchievementService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Get all achievement definitions.
|
||||
*/
|
||||
getAchievementDefinitions(): Observable<AchievementDefinition[]> {
|
||||
return this.api.get<AchievementDefinition[]>('/achievements/definitions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific achievement definition by key.
|
||||
*/
|
||||
getAchievementDefinition(key: string): Observable<AchievementDefinition> {
|
||||
return this.api.get<AchievementDefinition>(`/achievements/definitions/${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's achievement summary.
|
||||
*/
|
||||
getCurrentUserSummary(): Observable<UserAchievementSummary> {
|
||||
return this.api.get<UserAchievementSummary>('/achievements/summary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's achievement progress.
|
||||
*/
|
||||
getCurrentUserProgress(): Observable<AchievementProgress[]> {
|
||||
return this.api.get<AchievementProgress[]>('/achievements/progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get another user's achievement summary.
|
||||
*/
|
||||
getUserSummary(userId: string): Observable<UserAchievementSummary> {
|
||||
return this.api.get<UserAchievementSummary>(`/achievements/users/${userId}/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get another user's achievement progress.
|
||||
*/
|
||||
getUserProgress(userId: string): Observable<AchievementProgress[]> {
|
||||
return this.api.get<AchievementProgress[]>(`/achievements/users/${userId}/progress`);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface UserProfileResponse {
|
||||
linkedin?: string;
|
||||
twitch?: string;
|
||||
youtube?: string;
|
||||
achievementPoints: number;
|
||||
badges: {
|
||||
isStaff: boolean;
|
||||
isMod: boolean;
|
||||
|
||||
Reference in New Issue
Block a user