From 5eec4c76401be8f7982a1645d77d284132f9a754 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 17:59:10 -0800 Subject: [PATCH] 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 --- api/prisma/schema.prisma | 7 + api/src/app/routes/users/index.ts | 21 +++ api/src/app/services/auth.service.ts | 15 ++ api/src/app/services/user.service.ts | 63 +++++++++ .../components/profile/profile.component.ts | 113 ++++++++++++++- .../components/settings/settings.component.ts | 132 +++++++++++++++++- .../frontend/src/app/services/user.service.ts | 14 ++ package.json | 4 + pnpm-lock.yaml | 53 +++++++ shared-types/src/lib/auth.types.ts | 7 + 10 files changed, 425 insertions(+), 4 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 77b0191..0bf25a9 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -186,6 +186,13 @@ model User { displayName String? bio String? profilePublic Boolean @default(true) + website String? + discordServer String? + bluesky String? + github String? + linkedin String? + twitch String? + youtube String? isAdmin Boolean @default(false) isBanned Boolean @default(false) inDiscord Boolean @default(false) diff --git a/api/src/app/routes/users/index.ts b/api/src/app/routes/users/index.ts index 390c958..ccb5f51 100644 --- a/api/src/app/routes/users/index.ts +++ b/api/src/app/routes/users/index.ts @@ -15,6 +15,13 @@ interface UpdateUserSettingsBody { displayName?: string; bio?: string; profilePublic?: boolean; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; } interface UserProfileResponse { @@ -24,6 +31,13 @@ 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; @@ -130,6 +144,13 @@ const usersRoutes: FastifyPluginAsync = async (app) => { avatar: profile.avatar, bio: profile.bio, slug: profile.slug, + website: profile.website, + discordServer: profile.discordServer, + bluesky: profile.bluesky, + github: profile.github, + linkedin: profile.linkedin, + twitch: profile.twitch, + youtube: profile.youtube, badges: { isStaff: profile.isStaff, isMod: profile.isMod, diff --git a/api/src/app/services/auth.service.ts b/api/src/app/services/auth.service.ts index 10ad88d..f1ca503 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -75,6 +75,11 @@ export class AuthService { displayName: dbUser.displayName || undefined, bio: dbUser.bio || undefined, profilePublic: dbUser.profilePublic, + website: dbUser.website || undefined, + discordServer: dbUser.discordServer || undefined, + bluesky: dbUser.bluesky || undefined, + github: dbUser.github || undefined, + linkedin: dbUser.linkedin || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -175,6 +180,11 @@ export class AuthService { displayName: dbUser.displayName || undefined, bio: dbUser.bio || undefined, profilePublic: dbUser.profilePublic, + website: dbUser.website || undefined, + discordServer: dbUser.discordServer || undefined, + bluesky: dbUser.bluesky || undefined, + github: dbUser.github || undefined, + linkedin: dbUser.linkedin || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -229,6 +239,11 @@ export class AuthService { displayName: dbUser.displayName || undefined, bio: dbUser.bio || undefined, profilePublic: dbUser.profilePublic, + website: dbUser.website || undefined, + discordServer: dbUser.discordServer || undefined, + bluesky: dbUser.bluesky || undefined, + github: dbUser.github || undefined, + linkedin: dbUser.linkedin || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index ad6009d..7540c6f 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -26,6 +26,13 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -54,6 +61,13 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -79,6 +93,13 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -104,6 +125,13 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -141,6 +169,13 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -157,6 +192,13 @@ export class UserService { displayName?: string; bio?: string; profilePublic?: boolean; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; } ): Promise { const user = await this.prisma.user.update({ @@ -174,6 +216,13 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -190,6 +239,13 @@ export class UserService { avatar?: string | null; bio?: string | null; slug?: string | null; + website?: string | null; + discordServer?: string | null; + bluesky?: string | null; + github?: string | null; + linkedin?: string | null; + twitch?: string | null; + youtube?: string | null; isStaff: boolean; isMod: boolean; isVip: boolean; @@ -238,6 +294,13 @@ export class UserService { avatar: user.avatar, bio: user.bio, slug: user.slug, + website: user.website, + discordServer: user.discordServer, + bluesky: user.bluesky, + github: user.github, + linkedin: user.linkedin, + twitch: user.twitch, + youtube: user.youtube, isStaff: user.isStaff, isMod: user.isMod, isVip: user.isVip, diff --git a/apps/frontend/src/app/components/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts index a29a1af..f728627 100644 --- a/apps/frontend/src/app/components/profile/profile.component.ts +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -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: `
@if (loading()) { @@ -62,6 +65,56 @@ import { ToastService } from '../../services/toast.service';
} + @if (profile()!.website || profile()!.github || profile()!.bluesky || profile()!.linkedin || profile()!.twitch || profile()!.youtube || profile()!.discordServer) { + + } +

Activity Statistics

@@ -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(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) { diff --git a/apps/frontend/src/app/components/settings/settings.component.ts b/apps/frontend/src/app/components/settings/settings.component.ts index 66f5ca6..49a6b22 100644 --- a/apps/frontend/src/app/components/settings/settings.component.ts +++ b/apps/frontend/src/app/components/settings/settings.component.ts @@ -71,6 +71,101 @@ import { User } from '@library/shared-types';
+
+

Social Links

+ +
+ + + Your personal website or portfolio (must start with http:// or https://) +
+ +
+ + + Just your GitHub username (alphanumeric and hyphens, 1-39 characters) +
+ +
+ + + Your full Bluesky handle (e.g., username.bsky.social) +
+ +
+ + + Just your LinkedIn username (alphanumeric and hyphens, 3-100 characters) +
+ +
+ + + Just your Twitch username (alphanumeric and underscores, 4-25 characters) +
+ +
+ + + Your YouTube handle (@username) or channel ID (UC...) +
+ +
+ + + Just your Discord server invite code (alphanumeric, 2-32 characters) +
+
+

Privacy

@@ -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({ diff --git a/apps/frontend/src/app/services/user.service.ts b/apps/frontend/src/app/services/user.service.ts index 817c34f..2ac4d6d 100644 --- a/apps/frontend/src/app/services/user.service.ts +++ b/apps/frontend/src/app/services/user.service.ts @@ -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({ diff --git a/package.json b/package.json index ac30da9..bd197b0 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ "@fastify/rate-limit": "10.3.0", "@fastify/sensible": "6.0.4", "@fastify/static": "9.0.0", + "@fortawesome/angular-fontawesome": "4.0.0", + "@fortawesome/fontawesome-svg-core": "7.2.0", + "@fortawesome/free-brands-svg-icons": "7.2.0", + "@fortawesome/free-solid-svg-icons": "7.2.0", "@nhcarrigan/logger": "1.1.1", "@prisma/client": "6.19.2", "dompurify": "3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c258b9..4633e46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,18 @@ importers: '@fastify/static': specifier: 9.0.0 version: 9.0.0 + '@fortawesome/angular-fontawesome': + specifier: 4.0.0 + version: 4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)) + '@fortawesome/fontawesome-svg-core': + specifier: 7.2.0 + version: 7.2.0 + '@fortawesome/free-brands-svg-icons': + specifier: 7.2.0 + version: 7.2.0 + '@fortawesome/free-solid-svg-icons': + specifier: 7.2.0 + version: 7.2.0 '@nhcarrigan/logger': specifier: 1.1.1 version: 1.1.1 @@ -1659,6 +1671,27 @@ packages: '@fastify/static@9.0.0': resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + '@fortawesome/angular-fontawesome@4.0.0': + resolution: {integrity: sha512-TCqHqT5ovFY1A4RgMpoBUgS+RX3OVs39+CzHFgzDhbCPAopOa26J748TZJcuZwJAvGAk9tbWeVEmWuLByINAeg==} + peerDependencies: + '@angular/core': ^21.0.0 + + '@fortawesome/fontawesome-common-types@7.2.0': + resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==} + engines: {node: '>=6'} + + '@fortawesome/fontawesome-svg-core@7.2.0': + resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==} + engines: {node: '>=6'} + + '@fortawesome/free-brands-svg-icons@7.2.0': + resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==} + engines: {node: '>=6'} + + '@fortawesome/free-solid-svg-icons@7.2.0': + resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} + engines: {node: '>=6'} + '@hapi/boom@10.0.1': resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} @@ -11651,6 +11684,26 @@ snapshots: fastq: 1.20.1 glob: 13.0.1 + '@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))': + dependencies: + '@angular/core': 21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2) + '@fortawesome/fontawesome-svg-core': 7.2.0 + tslib: 2.8.1 + + '@fortawesome/fontawesome-common-types@7.2.0': {} + + '@fortawesome/fontawesome-svg-core@7.2.0': + dependencies: + '@fortawesome/fontawesome-common-types': 7.2.0 + + '@fortawesome/free-brands-svg-icons@7.2.0': + dependencies: + '@fortawesome/fontawesome-common-types': 7.2.0 + + '@fortawesome/free-solid-svg-icons@7.2.0': + dependencies: + '@fortawesome/fontawesome-common-types': 7.2.0 + '@hapi/boom@10.0.1': dependencies: '@hapi/hoek': 11.0.7 diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index 826edd3..03e0b66 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -13,6 +13,13 @@ interface User { displayName?: string; bio?: string; profilePublic: boolean; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; discordId: string; isAdmin: boolean; isBanned: boolean;