feat: implement user profiles with achievements and primary badge system #58

Merged
naomi merged 17 commits from feat/user-profiles into main 2026-02-19 22:21:18 -08:00
Showing only changes of commit bbc3b040d0 - Show all commits
@@ -598,6 +598,201 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR
</div> </div>
</div> </div>
} }
<!-- Profile Edit Modal -->
@if (editingProfile()) {
<div
class="modal-overlay"
(click)="closeProfileEditModal()"
(keydown.escape)="closeProfileEditModal()"
role="dialog"
aria-modal="true"
aria-labelledby="edit-profile-modal-title"
>
<div
class="modal-content"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="document"
tabindex="-1"
>
<div class="modal-header">
<h2 id="edit-profile-modal-title">Edit Profile: {{ editingProfile()!.username }}</h2>
<button
type="button"
class="modal-close"
(click)="closeProfileEditModal()"
aria-label="Close modal"
>
×
</button>
</div>
<div class="modal-body">
<form (ngSubmit)="saveProfileEdit()" class="profile-edit-form">
<div class="form-section">
<h3>Profile Information</h3>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
name="displayName"
[(ngModel)]="profileEditForm.displayName"
class="form-control"
placeholder="Display name"
maxlength="50"
/>
</div>
<div class="form-group">
<label for="slug">Profile URL Slug</label>
<input
type="text"
id="slug"
name="slug"
[(ngModel)]="profileEditForm.slug"
class="form-control"
placeholder="url-slug"
maxlength="30"
pattern="[a-z0-9-]+"
/>
<small class="form-help">Only lowercase letters, numbers, and hyphens</small>
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea
id="bio"
name="bio"
[(ngModel)]="profileEditForm.bio"
class="form-control"
rows="4"
maxlength="500"
placeholder="User bio..."
></textarea>
<small class="form-help">{{ (profileEditForm.bio.length || 0) }} / 500 characters</small>
</div>
</div>
<div class="form-section">
<h3>Social Links</h3>
<div class="form-group">
<label for="website">Website</label>
<input
type="text"
id="website"
name="website"
[(ngModel)]="profileEditForm.website"
class="form-control"
placeholder="https://example.com"
pattern="https?://.+"
/>
</div>
<div class="form-group">
<label for="discordServer">Discord Server</label>
<input
type="text"
id="discordServer"
name="discordServer"
[(ngModel)]="profileEditForm.discordServer"
class="form-control"
placeholder="https://discord.gg/..."
pattern="https://discord\\.gg/[a-zA-Z0-9]+"
/>
</div>
<div class="form-group">
<label for="github">GitHub</label>
<input
type="text"
id="github"
name="github"
[(ngModel)]="profileEditForm.github"
class="form-control"
placeholder="username"
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?"
/>
</div>
<div class="form-group">
<label for="bluesky">Bluesky</label>
<input
type="text"
id="bluesky"
name="bluesky"
[(ngModel)]="profileEditForm.bluesky"
class="form-control"
placeholder="username.bsky.social"
pattern="[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
/>
</div>
<div class="form-group">
<label for="linkedin">LinkedIn</label>
<input
type="text"
id="linkedin"
name="linkedin"
[(ngModel)]="profileEditForm.linkedin"
class="form-control"
placeholder="username"
pattern="[a-zA-Z0-9-]{3,100}"
/>
</div>
<div class="form-group">
<label for="twitch">Twitch</label>
<input
type="text"
id="twitch"
name="twitch"
[(ngModel)]="profileEditForm.twitch"
class="form-control"
placeholder="username"
pattern="[a-zA-Z0-9_]{4,25}"
/>
</div>
<div class="form-group">
<label for="youtube">YouTube</label>
<input
type="text"
id="youtube"
name="youtube"
[(ngModel)]="profileEditForm.youtube"
class="form-control"
placeholder="@username or channel-id"
pattern="(@[a-zA-Z0-9_.-]{3,30}|UC[a-zA-Z0-9_-]{22})"
/>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
(click)="closeProfileEditModal()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="submitting()"
>
{{ submitting() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</div>
</div>
}
</div> </div>
`, `,
styles: [` styles: [`
@@ -1137,6 +1332,29 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR
font-size: 0.95rem; font-size: 0.95rem;
} }
.form-help {
font-size: 0.85rem;
color: var(--witch-mauve);
font-style: italic;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--witch-lavender);
}
.form-section:last-of-type {
border-bottom: none;
padding-bottom: 0;
}
.form-section h3 {
color: var(--witch-purple);
margin-bottom: 1rem;
font-size: 1.1rem;
}
.form-control { .form-control {
padding: 0.75rem; padding: 0.75rem;
border: 2px solid var(--witch-lavender); border: 2px solid var(--witch-lavender);
@@ -1256,6 +1474,7 @@ export class AdminReportsComponent implements OnInit {
reviewingProfileReport = signal<ProfileReportWithUsers | null>(null); reviewingProfileReport = signal<ProfileReportWithUsers | null>(null);
reviewingCommentReport = signal<CommentReportWithDetails | null>(null); reviewingCommentReport = signal<CommentReportWithDetails | null>(null);
editingProfile = signal<{ userId: string; username: string; profile: any } | null>(null);
submitting = signal(false); submitting = signal(false);
reviewForm = { reviewForm = {
@@ -1263,6 +1482,20 @@ export class AdminReportsComponent implements OnInit {
reviewNotes: '' reviewNotes: ''
}; };
profileEditForm = {
displayName: '',
slug: '',
bio: '',
profilePublic: true,
website: '',
discordServer: '',
bluesky: '',
github: '',
linkedin: '',
twitch: '',
youtube: ''
};
filteredProfileReports = computed(() => { filteredProfileReports = computed(() => {
const filter = this.activeFilter(); const filter = this.activeFilter();
if (filter === 'all') { if (filter === 'all') {
@@ -1534,20 +1767,23 @@ export class AdminReportsComponent implements OnInit {
// Load the full profile first to get current values // Load the full profile first to get current values
this.userService.getProfile(userId).subscribe({ this.userService.getProfile(userId).subscribe({
next: (profile) => { next: (profile) => {
const newBio = prompt(`Edit bio for ${username}:`, profile.bio || ''); // Populate the edit form with current values
this.profileEditForm = {
displayName: profile.displayName || '',
slug: profile.slug || '',
bio: profile.bio || '',
profilePublic: true, // We'll get this from the full user object if needed
website: profile.website || '',
discordServer: profile.discordServer || '',
bluesky: profile.bluesky || '',
github: profile.github || '',
linkedin: profile.linkedin || '',
twitch: profile.twitch || '',
youtube: profile.youtube || ''
};
if (newBio !== null) { // Open the edit modal
this.userService.adminUpdateUser(userId, { bio: newBio }).subscribe({ this.editingProfile.set({ userId, username, profile });
next: () => {
this.toastService.success('Profile updated successfully');
this.loadReports();
this.closeProfileReviewModal();
},
error: (err) => {
this.toastService.error(err.message ?? 'Failed to update profile');
}
});
}
}, },
error: (err) => { error: (err) => {
this.toastService.error(err.message ?? 'Failed to load profile'); this.toastService.error(err.message ?? 'Failed to load profile');
@@ -1555,6 +1791,43 @@ export class AdminReportsComponent implements OnInit {
}); });
} }
closeProfileEditModal(): void {
this.editingProfile.set(null);
this.profileEditForm = {
displayName: '',
slug: '',
bio: '',
profilePublic: true,
website: '',
discordServer: '',
bluesky: '',
github: '',
linkedin: '',
twitch: '',
youtube: ''
};
}
saveProfileEdit(): void {
const editing = this.editingProfile();
if (!editing) return;
this.submitting.set(true);
this.userService.adminUpdateUser(editing.userId, this.profileEditForm).subscribe({
next: () => {
this.toastService.success('Profile updated successfully');
this.loadReports();
this.closeProfileEditModal();
this.closeProfileReviewModal();
this.submitting.set(false);
},
error: (err) => {
this.toastService.error(err.message ?? 'Failed to update profile');
this.submitting.set(false);
}
});
}
makeProfilePrivate(report: ProfileReportWithUsers): void { makeProfilePrivate(report: ProfileReportWithUsers): void {
const userId = report.reportedUser.id; const userId = report.reportedUser.id;
const username = report.reportedUser.username; const username = report.reportedUser.username;