feat: add social links with validation and Font Awesome icons

Added comprehensive social links functionality to user profiles:

**New Social Platforms:**
- Website (full URL validation)
- GitHub (username format)
- Bluesky (handle format)
- LinkedIn (username format)
- Twitch (username format)
- YouTube (handle or channel ID format)
- Discord Server (invite code format)

**Features:**
- Database schema updated with 7 new optional social link fields
- Backend services and API routes updated to handle all social links
- Settings form with input fields and helpful validation hints
- Profile display with Font Awesome icons for each platform
- Regex validation patterns for all fields with visual feedback
- Green border for valid input, red border for invalid input
- All form inputs use consistent type="text" for uniform styling
- Discord accepts just invite code (constructs full URL automatically)

**Technical Changes:**
- Installed @fortawesome/angular-fontawesome with pinned versions
- Replaced emoji icons with proper Font Awesome components
- Added FontAwesomeModule to profile component
- Updated all User type interfaces across frontend and backend
- Updated UserService mappings in all methods
- Added comprehensive regex patterns matching platform requirements

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 17:59:10 -08:00
parent 34c7ca8ba2
commit 5eec4c7640
10 changed files with 425 additions and 4 deletions
@@ -7,13 +7,16 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faGlobe, faCloud } 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 { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FontAwesomeModule],
template: `
<div class="profile-container">
@if (loading()) {
@@ -62,6 +65,56 @@ import { ToastService } from '../../services/toast.service';
</div>
}
@if (profile()!.website || profile()!.github || profile()!.bluesky || profile()!.linkedin || profile()!.twitch || profile()!.youtube || profile()!.discordServer) {
<div class="social-links-section">
<h2>Social Links</h2>
<div class="social-links">
@if (profile()!.website) {
<a [href]="profile()!.website" target="_blank" rel="noopener noreferrer" class="social-link" title="Website">
<fa-icon [icon]="faGlobe" class="icon"></fa-icon>
<span class="label">Website</span>
</a>
}
@if (profile()!.github) {
<a [href]="'https://github.com/' + profile()!.github" target="_blank" rel="noopener noreferrer" class="social-link" title="GitHub">
<fa-icon [icon]="faGithub" class="icon"></fa-icon>
<span class="label">GitHub</span>
</a>
}
@if (profile()!.bluesky) {
<a [href]="'https://bsky.app/profile/' + profile()!.bluesky" target="_blank" rel="noopener noreferrer" class="social-link" title="Bluesky">
<fa-icon [icon]="faCloud" class="icon"></fa-icon>
<span class="label">Bluesky</span>
</a>
}
@if (profile()!.linkedin) {
<a [href]="'https://linkedin.com/in/' + profile()!.linkedin" target="_blank" rel="noopener noreferrer" class="social-link" title="LinkedIn">
<fa-icon [icon]="faLinkedin" class="icon"></fa-icon>
<span class="label">LinkedIn</span>
</a>
}
@if (profile()!.twitch) {
<a [href]="'https://twitch.tv/' + profile()!.twitch" target="_blank" rel="noopener noreferrer" class="social-link" title="Twitch">
<fa-icon [icon]="faTwitch" class="icon"></fa-icon>
<span class="label">Twitch</span>
</a>
}
@if (profile()!.youtube) {
<a [href]="'https://youtube.com/' + profile()!.youtube" target="_blank" rel="noopener noreferrer" class="social-link" title="YouTube">
<fa-icon [icon]="faYoutube" class="icon"></fa-icon>
<span class="label">YouTube</span>
</a>
}
@if (profile()!.discordServer) {
<a [href]="'https://discord.gg/' + profile()!.discordServer" target="_blank" rel="noopener noreferrer" class="social-link" title="Discord Server">
<fa-icon [icon]="faDiscord" class="icon"></fa-icon>
<span class="label">Discord</span>
</a>
}
</div>
</div>
}
<div class="stats-section">
<h2>Activity Statistics</h2>
<div class="stats-grid">
@@ -212,6 +265,55 @@ import { ToastService } from '../../services/toast.service';
white-space: pre-wrap;
}
.social-links-section {
margin-bottom: 1.5rem;
}
.social-links-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.social-links {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 8px;
color: var(--text-colour, #e0e0e0);
text-decoration: none;
transition: all 0.2s ease;
}
.social-link:hover {
background: rgba(155, 89, 182, 0.3);
border-color: var(--accent-colour, #9b59b6);
transform: translateY(-2px);
}
.social-link fa-icon {
font-size: 1.3rem;
width: 1.5rem;
}
.social-link fa-icon ::ng-deep svg {
width: 1.3rem;
height: 1.3rem;
}
.social-link .label {
font-weight: 500;
}
.stats-section {
margin-bottom: 1.5rem;
}
@@ -272,6 +374,15 @@ export class ProfileComponent implements OnInit {
loading = signal(true);
error = signal<string | null>(null);
// Font Awesome icons
faGlobe = faGlobe;
faGithub = faGithub;
faCloud = faCloud;
faLinkedin = faLinkedin;
faTwitch = faTwitch;
faYoutube = faYoutube;
faDiscord = faDiscord;
ngOnInit(): void {
const identifier = this.route.snapshot.paramMap.get('identifier');
if (!identifier) {
@@ -71,6 +71,101 @@ import { User } from '@library/shared-types';
</div>
</div>
<div class="form-section">
<h2>Social Links</h2>
<div class="form-group">
<label for="website">Website</label>
<input
type="text"
id="website"
name="website"
[(ngModel)]="formData.website"
placeholder="https://yourwebsite.com"
pattern="https?://.+"
/>
<small class="form-help">Your personal website or portfolio (must start with http:// or https://)</small>
</div>
<div class="form-group">
<label for="github">GitHub</label>
<input
type="text"
id="github"
name="github"
[(ngModel)]="formData.github"
placeholder="username"
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?"
/>
<small class="form-help">Just your GitHub username (alphanumeric and hyphens, 1-39 characters)</small>
</div>
<div class="form-group">
<label for="bluesky">Bluesky</label>
<input
type="text"
id="bluesky"
name="bluesky"
[(ngModel)]="formData.bluesky"
placeholder="username.bsky.social"
pattern="[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
/>
<small class="form-help">Your full Bluesky handle (e.g., username.bsky.social)</small>
</div>
<div class="form-group">
<label for="linkedin">LinkedIn</label>
<input
type="text"
id="linkedin"
name="linkedin"
[(ngModel)]="formData.linkedin"
placeholder="username"
pattern="[a-zA-Z0-9-]{3,100}"
/>
<small class="form-help">Just your LinkedIn username (alphanumeric and hyphens, 3-100 characters)</small>
</div>
<div class="form-group">
<label for="twitch">Twitch</label>
<input
type="text"
id="twitch"
name="twitch"
[(ngModel)]="formData.twitch"
placeholder="username"
pattern="[a-zA-Z0-9_]{4,25}"
/>
<small class="form-help">Just your Twitch username (alphanumeric and underscores, 4-25 characters)</small>
</div>
<div class="form-group">
<label for="youtube">YouTube</label>
<input
type="text"
id="youtube"
name="youtube"
[(ngModel)]="formData.youtube"
placeholder="@username or channel-id"
pattern="(@[a-zA-Z0-9_.-]{3,30}|UC[a-zA-Z0-9_-]{22})"
/>
<small class="form-help">Your YouTube handle (@username) or channel ID (UC...)</small>
</div>
<div class="form-group">
<label for="discordServer">Discord Server</label>
<input
type="text"
id="discordServer"
name="discordServer"
[(ngModel)]="formData.discordServer"
placeholder="invite-code"
pattern="[a-zA-Z0-9]{2,32}"
/>
<small class="form-help">Just your Discord server invite code (alphanumeric, 2-32 characters)</small>
</div>
</div>
<div class="form-section">
<h2>Privacy</h2>
@@ -187,6 +282,16 @@ import { User } from '@library/shared-types';
box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3);
}
.form-group input[type="text"]:invalid:not(:focus):not(:placeholder-shown),
.form-group textarea:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--error-colour, #c41e3a);
}
.form-group input[type="text"]:valid:not(:placeholder-shown),
.form-group textarea:valid:not(:placeholder-shown) {
border-color: rgba(46, 204, 113, 0.5);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
@@ -274,7 +379,14 @@ export class SettingsComponent implements OnInit {
displayName: '',
slug: '',
bio: '',
profilePublic: true
profilePublic: true,
website: '',
discordServer: '',
bluesky: '',
github: '',
linkedin: '',
twitch: '',
youtube: ''
};
ngOnInit(): void {
@@ -285,7 +397,14 @@ export class SettingsComponent implements OnInit {
displayName: userData.displayName || '',
slug: userData.slug || '',
bio: userData.bio || '',
profilePublic: userData.profilePublic ?? true
profilePublic: userData.profilePublic ?? true,
website: userData.website || '',
discordServer: userData.discordServer || '',
bluesky: userData.bluesky || '',
github: userData.github || '',
linkedin: userData.linkedin || '',
twitch: userData.twitch || '',
youtube: userData.youtube || ''
};
this.loading.set(false);
},
@@ -304,7 +423,14 @@ export class SettingsComponent implements OnInit {
displayName: this.formData.displayName || undefined,
slug: this.formData.slug || undefined,
bio: this.formData.bio || undefined,
profilePublic: this.formData.profilePublic
profilePublic: this.formData.profilePublic,
website: this.formData.website || undefined,
discordServer: this.formData.discordServer || undefined,
bluesky: this.formData.bluesky || undefined,
github: this.formData.github || undefined,
linkedin: this.formData.linkedin || undefined,
twitch: this.formData.twitch || undefined,
youtube: this.formData.youtube || undefined
};
this.userService.updateSettings(updates).subscribe({
@@ -16,6 +16,13 @@ export interface UserProfileResponse {
avatar?: string;
bio?: string;
slug?: string;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
badges: {
isStaff: boolean;
isMod: boolean;
@@ -36,6 +43,13 @@ export interface UpdateUserSettingsRequest {
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
@Injectable({