generated from nhcarrigan/template
86404497f0
## Summary This PR implements comprehensive user profile enhancements including: - User profile pages showing stats, badges, social links, and bio - Achievement system with 62 achievements across 5 categories - Primary badge selection allowing users to display their preferred badge - Admin profile editing capabilities ## Changes ### User Profiles (#45) - **Frontend**: User profile pages with stats display - Profile cards showing avatar, display name, username, and bio - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord) - Stats display (suggestions, accepted suggestions, likes, comments) - Recent achievements section - Badge display - Report button for other users' profiles - **Backend**: Profile API endpoints - Get user profile by username or ID - Profile includes stats, badges, and achievement points ### Achievement System (#48) - **Database**: UserAchievement model for tracking progress - **62 Total Achievements** across 5 categories: - **Suggestions (15)**: First suggestion through ultimate curator - **Likes (12)**: First like through legendary fan - **Comments (12)**: First comment through review legend - **Engagement (15)**: Login streaks and activity milestones - **Reports (8)**: Valid reports and accuracy tracking - **Backend**: AchievementService with real-time checking - Integrated into all user interaction points - API endpoints for achievement data - Progress tracking to avoid recalculation - **Frontend**: Achievements page and profile integration - Full achievements page with category filtering - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Recent achievements on profile pages ### Primary Badge System (#49) - **Database**: Add primaryBadge field to User model - **Backend**: Update profile endpoints to include primary badge - **Frontend**: Primary badge selection in settings - Only shows badges the user has earned - Displayed on profile page - Displayed in comments (next to username) - Falls back to no badge if selection is invalid - **Admin Features**: Admin can edit any user's primary badge ### Admin Enhancements - Comprehensive profile editing modal for admins - Edit display name, bio, slug, social links - Set primary badge for users - Visual feedback for save/error states - Admin action buttons in report review modals - Ban user, delete comment, edit profile - Integrated with report workflow ### Quality Improvements - Improved dropdown option contrast for readability - Hide all badges when no primary badge is selected - "View All" achievements link only shown on own profile - Improved achievement text readability ## Testing - ✅ User profiles display correctly with stats and badges - ✅ Achievement checking works for all interaction types - ✅ Primary badge selection persists and displays correctly - ✅ Admin profile editing saves successfully - ✅ Report workflow integrated with admin actions - ✅ Achievements page shows all 62 achievements with filtering - ✅ Text readability improved across components Closes #45 Closes #48 Closes #49 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #58 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
438 lines
12 KiB
TypeScript
438 lines
12 KiB
TypeScript
/**
|
|
* @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`;
|
|
}
|
|
}
|