feat: add social media links to user profile
- Add YouTube, Instagram, Facebook, and TikTok URL fields to User model - Create database migration for social media link columns - Add custom validators to ensure URLs contain correct domains - Update profile page with social media input fields - Include social media URLs in GET /api/users/me response - Add icons for each social platform in the UI Users can now add links to their social media profiles. Each field validates that the URL contains the appropriate domain (e.g., instagram.com for Instagram, youtube.com/youtu.be for YouTube).
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "facebook_url" VARCHAR(255),
|
||||||
|
ADD COLUMN "instagram_url" VARCHAR(255),
|
||||||
|
ADD COLUMN "tiktok_url" VARCHAR(255),
|
||||||
|
ADD COLUMN "youtube_url" VARCHAR(255);
|
||||||
@@ -23,6 +23,12 @@ model User {
|
|||||||
lastName String? @map("last_name") @db.VarChar(100)
|
lastName String? @map("last_name") @db.VarChar(100)
|
||||||
wsdcId String? @unique @map("wsdc_id") @db.VarChar(20)
|
wsdcId String? @unique @map("wsdc_id") @db.VarChar(20)
|
||||||
|
|
||||||
|
// Social Media Links
|
||||||
|
youtubeUrl String? @map("youtube_url") @db.VarChar(255)
|
||||||
|
instagramUrl String? @map("instagram_url") @db.VarChar(255)
|
||||||
|
facebookUrl String? @map("facebook_url") @db.VarChar(255)
|
||||||
|
tiktokUrl String? @map("tiktok_url") @db.VarChar(255)
|
||||||
|
|
||||||
// Email Verification (Phase 1.5)
|
// Email Verification (Phase 1.5)
|
||||||
emailVerified Boolean @default(false) @map("email_verified")
|
emailVerified Boolean @default(false) @map("email_verified")
|
||||||
verificationToken String? @unique @map("verification_token") @db.VarChar(255)
|
verificationToken String? @unique @map("verification_token") @db.VarChar(255)
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ const { sanitizeForEmail } = require('../utils/sanitize');
|
|||||||
async function updateProfile(req, res, next) {
|
async function updateProfile(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const { firstName, lastName, email, wsdcId } = req.body;
|
const { firstName, lastName, email, wsdcId, youtubeUrl, instagramUrl, facebookUrl, tiktokUrl } = req.body;
|
||||||
|
|
||||||
// Build update data
|
// Build update data
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (firstName !== undefined) updateData.firstName = firstName;
|
if (firstName !== undefined) updateData.firstName = firstName;
|
||||||
if (lastName !== undefined) updateData.lastName = lastName;
|
if (lastName !== undefined) updateData.lastName = lastName;
|
||||||
if (wsdcId !== undefined) updateData.wsdcId = wsdcId || null; // Allow empty string to clear WSDC ID
|
if (wsdcId !== undefined) updateData.wsdcId = wsdcId || null; // Allow empty string to clear WSDC ID
|
||||||
|
if (youtubeUrl !== undefined) updateData.youtubeUrl = youtubeUrl || null;
|
||||||
|
if (instagramUrl !== undefined) updateData.instagramUrl = instagramUrl || null;
|
||||||
|
if (facebookUrl !== undefined) updateData.facebookUrl = facebookUrl || null;
|
||||||
|
if (tiktokUrl !== undefined) updateData.tiktokUrl = tiktokUrl || null;
|
||||||
|
|
||||||
// Check if email is being changed
|
// Check if email is being changed
|
||||||
const currentUser = await prisma.user.findUnique({
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -136,6 +136,38 @@ const updateProfileValidation = [
|
|||||||
.trim()
|
.trim()
|
||||||
.matches(/^\d{0,10}$/)
|
.matches(/^\d{0,10}$/)
|
||||||
.withMessage('WSDC ID must be numeric and up to 10 digits'),
|
.withMessage('WSDC ID must be numeric and up to 10 digits'),
|
||||||
|
body('youtubeUrl')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.custom((value) => {
|
||||||
|
if (!value) return true; // Allow empty
|
||||||
|
return value.includes('youtube.com') || value.includes('youtu.be');
|
||||||
|
})
|
||||||
|
.withMessage('Must be a valid YouTube URL (youtube.com or youtu.be)'),
|
||||||
|
body('instagramUrl')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.custom((value) => {
|
||||||
|
if (!value) return true; // Allow empty
|
||||||
|
return value.includes('instagram.com');
|
||||||
|
})
|
||||||
|
.withMessage('Must be a valid Instagram URL (instagram.com)'),
|
||||||
|
body('facebookUrl')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.custom((value) => {
|
||||||
|
if (!value) return true; // Allow empty
|
||||||
|
return value.includes('facebook.com') || value.includes('fb.com');
|
||||||
|
})
|
||||||
|
.withMessage('Must be a valid Facebook URL (facebook.com or fb.com)'),
|
||||||
|
body('tiktokUrl')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.custom((value) => {
|
||||||
|
if (!value) return true; // Allow empty
|
||||||
|
return value.includes('tiktok.com');
|
||||||
|
})
|
||||||
|
.withMessage('Must be a valid TikTok URL (tiktok.com)'),
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ router.get('/me', authenticate, async (req, res, next) => {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
wsdcId: true,
|
wsdcId: true,
|
||||||
|
youtubeUrl: true,
|
||||||
|
instagramUrl: true,
|
||||||
|
facebookUrl: true,
|
||||||
|
tiktokUrl: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { authAPI } from '../services/api';
|
import { authAPI } from '../services/api';
|
||||||
import Layout from '../components/layout/Layout';
|
import Layout from '../components/layout/Layout';
|
||||||
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2, Hash } from 'lucide-react';
|
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2, Hash, Youtube, Instagram, Facebook } from 'lucide-react';
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { user, updateUser } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
@@ -14,6 +14,10 @@ const ProfilePage = () => {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
wsdcId: '',
|
wsdcId: '',
|
||||||
|
youtubeUrl: '',
|
||||||
|
instagramUrl: '',
|
||||||
|
facebookUrl: '',
|
||||||
|
tiktokUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load user data when component mounts or user changes
|
// Load user data when component mounts or user changes
|
||||||
@@ -24,6 +28,10 @@ const ProfilePage = () => {
|
|||||||
lastName: user.lastName || '',
|
lastName: user.lastName || '',
|
||||||
email: user.email || '',
|
email: user.email || '',
|
||||||
wsdcId: user.wsdcId || '',
|
wsdcId: user.wsdcId || '',
|
||||||
|
youtubeUrl: user.youtubeUrl || '',
|
||||||
|
instagramUrl: user.instagramUrl || '',
|
||||||
|
facebookUrl: user.facebookUrl || '',
|
||||||
|
tiktokUrl: user.tiktokUrl || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -272,6 +280,95 @@ const ProfilePage = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Social Media Links Section */}
|
||||||
|
<div className="pt-4 border-t border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Social Media Links</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* YouTube */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
YouTube
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Youtube className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="youtubeUrl"
|
||||||
|
value={profileData.youtubeUrl}
|
||||||
|
onChange={handleProfileChange}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="https://youtube.com/@yourhandle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instagram */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Instagram
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Instagram className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="instagramUrl"
|
||||||
|
value={profileData.instagramUrl}
|
||||||
|
onChange={handleProfileChange}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="https://instagram.com/yourhandle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Facebook */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Facebook
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Facebook className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="facebookUrl"
|
||||||
|
value={profileData.facebookUrl}
|
||||||
|
onChange={handleProfileChange}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="https://facebook.com/yourhandle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TikTok */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
TikTok
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="tiktokUrl"
|
||||||
|
value={profileData.tiktokUrl}
|
||||||
|
onChange={handleProfileChange}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="https://tiktok.com/@yourhandle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user