diff --git a/apps/web/src/components/settings/profile/edit-profile/EditProfile.tsx b/apps/web/src/components/settings/profile/edit-profile/EditProfile.tsx index 44913780c..e115dbbe2 100644 --- a/apps/web/src/components/settings/profile/edit-profile/EditProfile.tsx +++ b/apps/web/src/components/settings/profile/edit-profile/EditProfile.tsx @@ -25,7 +25,9 @@ export const EditProfile: React.FC = () => {
setFormField('banner_url', url)} loading={loading} onFileChange={handleFileChange} onSave={handleSave} diff --git a/apps/web/src/components/settings/profile/edit-profile/EditProfileImagesCard.tsx b/apps/web/src/components/settings/profile/edit-profile/EditProfileImagesCard.tsx index 4ac2c5161..a3af84562 100644 --- a/apps/web/src/components/settings/profile/edit-profile/EditProfileImagesCard.tsx +++ b/apps/web/src/components/settings/profile/edit-profile/EditProfileImagesCard.tsx @@ -1,10 +1,14 @@ import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Camera, Upload, Save } from 'lucide-react'; interface EditProfileImagesCardProps { avatar: string; banner: string; + bannerUrl?: string; + onBannerUrlChange?: (url: string) => void; loading: boolean; onFileChange: (e: React.ChangeEvent, type: 'avatar' | 'banner') => void; onSave: () => void; @@ -13,14 +17,17 @@ interface EditProfileImagesCardProps { export function EditProfileImagesCard({ avatar, banner, + bannerUrl, + onBannerUrlChange, loading, onFileChange, onSave, }: EditProfileImagesCardProps) { + const bannerSrc = banner || 'https://via.placeholder.com/1200x400'; return (
- +
+ {onBannerUrlChange && ( +
+ + onBannerUrlChange(e.target.value)} + className="max-w-md" + /> +
+ )} ); } diff --git a/apps/web/src/components/settings/profile/edit-profile/types.ts b/apps/web/src/components/settings/profile/edit-profile/types.ts index 68aa4f8e8..5aacd2688 100644 --- a/apps/web/src/components/settings/profile/edit-profile/types.ts +++ b/apps/web/src/components/settings/profile/edit-profile/types.ts @@ -3,6 +3,7 @@ export interface EditProfileFormData { first_name: string; last_name: string; bio: string; + banner_url: string; location: string; gender: string; birthdate: string; diff --git a/apps/web/src/components/settings/profile/edit-profile/useEditProfile.ts b/apps/web/src/components/settings/profile/edit-profile/useEditProfile.ts index 6819bb274..076c0fc5c 100644 --- a/apps/web/src/components/settings/profile/edit-profile/useEditProfile.ts +++ b/apps/web/src/components/settings/profile/edit-profile/useEditProfile.ts @@ -11,6 +11,7 @@ const initialFormData: EditProfileFormData = { first_name: '', last_name: '', bio: '', + banner_url: '', location: '', gender: 'Prefer not to say', birthdate: '', @@ -38,12 +39,15 @@ export function useEditProfile() { first_name: p.first_name || '', last_name: p.last_name || '', bio: p.bio || '', + banner_url: p.banner_url || '', location: p.location || '', gender: p.gender || 'Prefer not to say', birthdate: p.birthdate || '', }); - if (p.avatar) setAvatar(p.avatar); - if (p.banner) setBanner(p.banner); + if (p.avatar_url) setAvatar(p.avatar_url); + if (p.banner_url) { + setBanner(p.banner_url); + } } catch (e) { logger.error('Failed to load profile settings', { error: e instanceof Error ? e.message : String(e), diff --git a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx index 80489814a..58072c442 100644 --- a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx +++ b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePage.tsx @@ -45,7 +45,7 @@ export function UserProfilePage() { return (
- +
- {/* Base gradient layer */} -
+ {bannerUrl ? ( + <> + +
+ + ) : ( + <> + {/* Base gradient layer */} +
{/* Radial highlight at center-top */}
@@ -29,6 +43,8 @@ export function UserProfilePageHero() { className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-background to-transparent" aria-hidden /> + + )}
); } diff --git a/apps/web/src/features/profile/services/profileService.ts b/apps/web/src/features/profile/services/profileService.ts index ae51e5de3..1597df52a 100644 --- a/apps/web/src/features/profile/services/profileService.ts +++ b/apps/web/src/features/profile/services/profileService.ts @@ -6,6 +6,7 @@ export interface UserProfile { first_name: string | null; last_name: string | null; avatar_url: string | null; + banner_url?: string | null; bio: string | null; location: string | null; birthdate: string | null; @@ -35,6 +36,7 @@ export interface UpdateProfileRequest { last_name?: string; username?: string; bio?: string; + banner_url?: string; location?: string; birthdate?: string; gender?: string; diff --git a/veza-backend-api/internal/handlers/profile_handler.go b/veza-backend-api/internal/handlers/profile_handler.go index 2d49ea7df..dd70b9d8a 100644 --- a/veza-backend-api/internal/handlers/profile_handler.go +++ b/veza-backend-api/internal/handlers/profile_handler.go @@ -510,6 +510,7 @@ type UpdateProfileRequest struct { Birthdate string `json:"birthdate" binding:"omitempty,datetime=2006-01-02" validate:"omitempty,datetime=2006-01-02"` Gender string `json:"gender" binding:"omitempty,oneof=Male Female Other 'Prefer not to say'" validate:"omitempty,oneof=Male Female Other 'Prefer not to say'"` SocialLinks map[string]interface{} `json:"social_links" binding:"omitempty"` + BannerURL string `json:"banner_url" binding:"omitempty,max=2048"` } // UpdateProfile updates a user profile @@ -636,6 +637,10 @@ func (h *ProfileHandler) UpdateProfile(c *gin.Context) { SocialLinks: req.SocialLinks, } + if req.BannerURL != "" { + serviceReq.BannerURL = &req.BannerURL + } + if req.Birthdate != "" { birthdate, _ := time.Parse("2006-01-02", req.Birthdate) birthdateStr := birthdate.Format("2006-01-02") diff --git a/veza-backend-api/internal/models/user.go b/veza-backend-api/internal/models/user.go index 61132b7df..c25c04c31 100644 --- a/veza-backend-api/internal/models/user.go +++ b/veza-backend-api/internal/models/user.go @@ -20,6 +20,7 @@ type User struct { FirstName string `gorm:"size:100" json:"first_name" db:"first_name"` LastName string `gorm:"size:100" json:"last_name" db:"last_name"` Avatar string `gorm:"type:text" json:"avatar" db:"avatar"` + BannerURL string `gorm:"type:text" json:"banner_url" db:"banner_url"` Bio string `gorm:"type:text" json:"bio" db:"bio"` Location string `gorm:"size:100" json:"location" db:"location"` Birthdate *time.Time `json:"birthdate" db:"birthdate"` diff --git a/veza-backend-api/internal/services/user_service.go b/veza-backend-api/internal/services/user_service.go index 4a8afc131..ef2927092 100644 --- a/veza-backend-api/internal/services/user_service.go +++ b/veza-backend-api/internal/services/user_service.go @@ -61,6 +61,7 @@ type Profile struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` AvatarURL *string `json:"avatar_url"` + BannerURL *string `json:"banner_url"` Bio *string `json:"bio"` Location *string `json:"location"` Birthdate *string `json:"birthdate"` @@ -297,6 +298,9 @@ func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileReq socialLinksJSON, _ := json.Marshal(req.SocialLinks) updates["social_links"] = string(socialLinksJSON) } + if req.BannerURL != nil { + updates["banner_url"] = *req.BannerURL + } // Apply updates to user object if firstname, ok := updates["first_name"].(string); ok { @@ -331,6 +335,9 @@ func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileReq if socialLinks, ok := updates["social_links"].(string); ok { user.SocialLinks = socialLinks } + if bannerURL, ok := updates["banner_url"].(string); ok { + user.BannerURL = bannerURL + } // Save changes err = s.userRepo.Update(user) @@ -349,6 +356,11 @@ func (s *UserService) userToProfile(user *models.User) *Profile { avatarURL = &user.Avatar } + var bannerURL *string + if user.BannerURL != "" { + bannerURL = &user.BannerURL + } + var bio *string if user.Bio != "" { bio = &user.Bio @@ -382,6 +394,7 @@ func (s *UserService) userToProfile(user *models.User) *Profile { FirstName: user.FirstName, LastName: user.LastName, AvatarURL: avatarURL, + BannerURL: bannerURL, Bio: bio, Location: location, Birthdate: birthdate, diff --git a/veza-backend-api/internal/types/user.go b/veza-backend-api/internal/types/user.go index 35388d6fe..9087744b3 100644 --- a/veza-backend-api/internal/types/user.go +++ b/veza-backend-api/internal/types/user.go @@ -13,6 +13,7 @@ type UpdateProfileRequest struct { Gender *string `json:"gender"` Timezone *string `json:"timezone"` SocialLinks map[string]interface{} `json:"social_links"` + BannerURL *string `json:"banner_url"` WebsiteURL *string `json:"website_url"` ProfilePrivacy *string `json:"profile_privacy"` } diff --git a/veza-backend-api/migrations/083_add_user_banner.sql b/veza-backend-api/migrations/083_add_user_banner.sql new file mode 100644 index 000000000..dbbcd9f76 --- /dev/null +++ b/veza-backend-api/migrations/083_add_user_banner.sql @@ -0,0 +1,6 @@ +-- Migration: Add user profile banner +-- Description: Adds banner_url column for customizable profile banner image + +ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_url TEXT DEFAULT ''; + +COMMENT ON COLUMN users.banner_url IS 'URL of user profile banner image';