feat(profile): add profile banner (B1)
This commit is contained in:
parent
a3fa564a50
commit
9be0e6e14f
12 changed files with 79 additions and 8 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export interface EditProfileFormData {
|
|||
first_name: string;
|
||||
last_name: string;
|
||||
bio: string;
|
||||
banner_url: string;
|
||||
location: string;
|
||||
gender: string;
|
||||
birthdate: string;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
6
veza-backend-api/migrations/083_add_user_banner.sql
Normal file
6
veza-backend-api/migrations/083_add_user_banner.sql
Normal 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';
|
||||
Loading…
Reference in a new issue