feat(profile): add profile banner (B1)

This commit is contained in:
senke 2026-02-20 14:56:25 +01:00
parent a3fa564a50
commit 9be0e6e14f
12 changed files with 79 additions and 8 deletions

View file

@ -25,7 +25,9 @@ export const EditProfile: React.FC = () => {
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto">
<EditProfileImagesCard
avatar={avatar}
banner={banner}
banner={formData.banner_url || banner}
bannerUrl={formData.banner_url}
onBannerUrlChange={(url) => setFormField('banner_url', url)}
loading={loading}
onFileChange={handleFileChange}
onSave={handleSave}

View file

@ -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<HTMLInputElement>, 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 (
<Card variant="default" className="p-0 overflow-hidden relative group">
<div className="h-48 md:h-64 bg-card relative">
<img src={banner} alt="" className="w-full h-full object-cover opacity-80" />
<img src={bannerSrc} alt="" className="w-full h-full object-cover opacity-80" />
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<label className="cursor-pointer bg-black/60 hover:bg-black/80 text-foreground px-4 py-2 rounded-lg flex items-center gap-2 backdrop-blur border border-white/10">
<Camera className="w-4 h-4" /> Change Banner
@ -59,6 +66,19 @@ export function EditProfileImagesCard({
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
{onBannerUrlChange && (
<div className="px-8 pb-4 space-y-2">
<Label htmlFor="banner-url">Banner URL</Label>
<Input
id="banner-url"
type="url"
placeholder="https://example.com/banner.jpg"
value={bannerUrl ?? ''}
onChange={(e) => onBannerUrlChange(e.target.value)}
className="max-w-md"
/>
</div>
)}
</Card>
);
}

View file

@ -3,6 +3,7 @@ export interface EditProfileFormData {
first_name: string;
last_name: string;
bio: string;
banner_url: string;
location: string;
gender: string;
birthdate: string;

View file

@ -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),

View file

@ -45,7 +45,7 @@ export function UserProfilePage() {
return (
<div className="min-h-screen pb-24 animate-fade-in">
<UserProfilePageHero />
<UserProfilePageHero bannerUrl={profile.banner_url} />
<div className="container mx-auto px-4 md:px-8 relative -mt-24 z-10">
<UserProfilePageHeader
profile={profile}

View file

@ -1,9 +1,23 @@
interface UserProfilePageHeroProps {
bannerUrl?: string | null;
}
export function UserProfilePageHero() {
export function UserProfilePageHero({ bannerUrl }: UserProfilePageHeroProps) {
return (
<div className="h-64 md:h-80 w-full relative overflow-hidden">
{/* Base gradient layer */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/30" />
{bannerUrl ? (
<>
<img
src={bannerUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent" />
</>
) : (
<>
{/* Base gradient layer */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/30" />
{/* Radial highlight at center-top */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/20 via-transparent to-transparent" />
@ -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
/>
</>
)}
</div>
);
}

View file

@ -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;

View file

@ -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")

View file

@ -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"`

View file

@ -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,

View file

@ -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"`
}

View file

@ -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';