feat(profile): add profile privacy toggle (B3)

- Backend: is_public in Profile, UpdateProfile; strip SocialLinks for private
- Settings: ProfileVisibilityCard toggle in Privacy tab
- UserProfilePage: show 'Profil privé' when viewing private profile
This commit is contained in:
senke 2026-02-20 15:10:02 +01:00
parent 09f9dc3de6
commit b33c9d3cca
10 changed files with 200 additions and 6 deletions

View file

@ -4,6 +4,7 @@ import { useUserProfilePage } from './useUserProfilePage';
import { UserProfilePageHero } from './UserProfilePageHero';
import { UserProfilePageError } from './UserProfilePageError';
import { UserProfilePageHeader } from './UserProfilePageHeader';
import { UserProfilePagePrivate } from './UserProfilePagePrivate';
import { UserProfilePageTabs } from './UserProfilePageTabs';
import { UserProfilePageSkeleton } from './UserProfilePageSkeleton';
@ -15,6 +16,7 @@ export function UserProfilePage() {
isLoading,
error,
isNotFound,
isPrivateView,
tracksData,
playlistsData,
postsData,
@ -43,6 +45,16 @@ export function UserProfilePage() {
return <UserProfilePageError isNotFound={isNotFound} onRetry={() => window.location.reload()} />;
}
if (isPrivateView) {
return (
<UserProfilePagePrivate
profile={profile}
displayName={displayName}
initials={initials}
/>
);
}
return (
<div className="min-h-screen pb-24 animate-fade-in">
<UserProfilePageHero bannerUrl={profile.banner_url} />

View file

@ -0,0 +1,62 @@
/**
* B3: Vue "Profil privé" quand le profil est masqué et le visiteur n'est pas le propriétaire.
*/
import { EyeOff } from 'lucide-react';
import { Avatar } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
import { UserProfilePageHero } from './UserProfilePageHero';
import type { UserProfile } from '@/services/api/users';
interface UserProfilePagePrivateProps {
profile: UserProfile;
displayName: string;
initials: string;
}
export function UserProfilePagePrivate({
profile,
displayName,
initials,
}: UserProfilePagePrivateProps) {
return (
<div className="min-h-screen pb-24 animate-fade-in">
<UserProfilePageHero bannerUrl={profile.banner_url} />
<div className="container mx-auto px-4 md:px-8 relative -mt-24 z-10">
<Card
variant="glass"
className="mb-8 overflow-visible border-border shadow-cover-depth bg-card/80 backdrop-blur-2xl animate-slide-up"
>
<CardContent className="pt-0 pb-8 px-6 md:px-10">
<div className="flex flex-col md:flex-row items-center md:items-end gap-6 md:gap-10">
<div className="relative -mt-16 md:-mt-20">
<Avatar
src={profile.avatar_url ?? undefined}
alt={profile.username}
fallback={initials}
size="3xl"
className="h-32 w-32 md:h-40 md:w-40 rounded-3xl border-4 border-background shadow-2xl"
/>
</div>
<div className="flex-1 pt-4 md:pt-6 text-center md:text-left">
<h1 className="text-4xl md:text-5xl font-heading font-bold text-foreground tracking-tight mb-2">
{displayName}
</h1>
<p className="text-muted-foreground">@{profile.username}</p>
</div>
</div>
<div className="mt-10 py-12 flex flex-col items-center justify-center text-center rounded-2xl border border-dashed border-border bg-muted/30">
<EyeOff className="h-16 w-16 text-muted-foreground mb-4" aria-hidden />
<h2 className="text-xl font-heading font-bold text-foreground mb-2">
Profil privé
</h2>
<p className="text-muted-foreground max-w-md">
Ce profil est masqué. Son contenu n&apos;est pas visible.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { useUser } from '@/features/auth/hooks/useUser';
import { usersApi, type UserProfile } from '@/services/api/users';
import { tracksApi } from '@/services/api/tracks';
import { playlistsApi } from '@/services/api/playlists';
@ -9,6 +10,7 @@ import { getUserRoles } from '@/features/roles/services/roleService';
export function useUserProfilePage() {
const { username } = useParams<{ username: string }>();
const { data: currentUser } = useUser();
const {
data: profile,
@ -76,6 +78,12 @@ export function useUserProfilePage() {
typeof (error as unknown as { response?: { status?: number } })?.response?.status === 'number' &&
(error as unknown as { response: { status: number } }).response.status === 404;
const isPrivateView =
!!profile &&
profile.is_public === false &&
!!currentUser &&
profile.id !== currentUser.id;
return {
username: username ?? null,
profile,
@ -92,5 +100,6 @@ export function useUserProfilePage() {
displayName,
initials,
memberSince,
isPrivateView,
};
}

View file

@ -15,11 +15,12 @@ export interface UserProfile {
followers_count?: number;
following_count?: number;
social_links?: Record<string, any>;
is_public?: boolean;
}
export async function getProfile(userId: string): Promise<UserProfile> {
const response = await apiClient.get(`/users/${userId}`);
return response.data.profile;
return (response.data as { profile?: UserProfile })?.profile ?? (response.data as UserProfile);
}
export async function getProfileByUsername(
@ -41,6 +42,7 @@ export interface UpdateProfileRequest {
birthdate?: string;
gender?: string;
social_links?: Record<string, string>;
is_public?: boolean;
}
export async function updateProfile(

View file

@ -0,0 +1,84 @@
/**
* B3: Toggle "Profil public" dans Settings Confidentialité.
* Persiste via updateProfile(is_public).
*/
import { useUser } from '@/features/auth/hooks/useUser';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '@/services/api/users';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Eye, EyeOff } from 'lucide-react';
import toast from '@/utils/toast';
export function ProfileVisibilityCard() {
const { data: user } = useUser();
const queryClient = useQueryClient();
const { data: profile, isLoading } = useQuery({
queryKey: ['profile', user?.id],
queryFn: () => usersApi.getProfile(user!.id),
enabled: !!user?.id,
});
const mutation = useMutation({
mutationFn: (isPublic: boolean) =>
usersApi.updateProfile(user!.id, { is_public: isPublic }),
onSuccess: (updatedProfile) => {
queryClient.setQueryData(['profile', user?.id], updatedProfile);
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
toast.success(
updatedProfile?.is_public !== false
? 'Profil rendu public'
: 'Profil rendu privé',
);
},
onError: () => {
toast.error('Impossible de modifier la visibilité du profil');
},
});
const isPublic = profile?.is_public ?? true;
if (!user?.id) return null;
if (isLoading) {
return (
<div className="space-y-4 rounded-xl border border-border bg-muted/20 p-6">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-6 w-48" />
</div>
);
}
return (
<div className="space-y-4 rounded-xl border border-border bg-muted/20 p-6">
<div className="flex items-center gap-2">
{isPublic ? (
<Eye className="h-5 w-5 text-muted-foreground" aria-hidden />
) : (
<EyeOff className="h-5 w-5 text-muted-foreground" aria-hidden />
)}
<h3 className="font-semibold text-foreground">Visibilité du profil</h3>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="profile_visibility">
Profil public
</Label>
<p className="text-sm text-muted-foreground">
{isPublic
? 'Votre profil est visible par tous. Désactivez pour le masquer.'
: 'Votre profil est masqué. Les autres voient uniquement "Profil privé".'}
</p>
</div>
<Switch
id="profile_visibility"
checked={isPublic}
disabled={mutation.isPending}
onCheckedChange={(checked) => mutation.mutate(checked === true)}
/>
</div>
</div>
);
}

View file

@ -6,6 +6,7 @@ import {
import { PreferenceSettings } from './PreferenceSettings';
import { NotificationSettings } from './NotificationSettings';
import { PrivacySettings } from './PrivacySettings';
import { ProfileVisibilityCard } from './ProfileVisibilityCard';
import { AccountSettings } from './AccountSettings';
import { PlaybackSettings } from './PlaybackSettings';
@ -86,7 +87,8 @@ export function SettingsTabs({ settings, onChange }: SettingsTabsProps) {
/>
</TabsContent>
<TabsContent value="privacy" className="mt-6">
<TabsContent value="privacy" className="mt-6 space-y-6">
<ProfileVisibilityCard />
<PrivacySettings
privacy={settings.privacy}
onChange={handlePrivacyChange}

View file

@ -217,6 +217,7 @@ export const handlersMisc = [
if (params.username === 'notfound') {
return HttpResponse.json({ message: 'User not found' }, { status: 404 });
}
const isPrivate = params.username === 'privateuser';
return HttpResponse.json({
profile: {
id: '123',
@ -240,6 +241,7 @@ export const handlersMisc = [
instagram: 'https://instagram.com/veza_profile',
}
: undefined,
is_public: !isPrivate,
},
});
}),
@ -257,14 +259,21 @@ export const handlersMisc = [
bio: 'Music enthusiast',
location: 'Paris, France',
website: 'https://example.com',
is_public: true,
},
});
}),
http.put('*/api/v1/users/:id', ({ params }) => {
http.put('*/api/v1/users/:id', async ({ params, request }) => {
const body = (await request.json()) as { is_public?: boolean };
return HttpResponse.json({
success: true,
data: { id: params.id, username: 'UpdatedUser', email: 'user@example.com' },
data: {
id: params.id,
username: 'UpdatedUser',
email: 'user@example.com',
is_public: body?.is_public ?? true,
},
});
}),

View file

@ -510,7 +510,8 @@ 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"`
BannerURL string `json:"banner_url" binding:"omitempty,max=2048"`
IsPublic *bool `json:"is_public" binding:"omitempty"`
}
// UpdateProfile updates a user profile
@ -646,6 +647,9 @@ func (h *ProfileHandler) UpdateProfile(c *gin.Context) {
birthdateStr := birthdate.Format("2006-01-02")
serviceReq.BirthDate = &birthdateStr
}
if req.IsPublic != nil {
serviceReq.IsPublic = req.IsPublic
}
// Update profile using the new UpdateProfile method
profile, err := h.userService.UpdateProfile(userID, serviceReq)

View file

@ -67,7 +67,8 @@ type Profile struct {
Birthdate *string `json:"birthdate"`
Gender *string `json:"gender"`
SocialLinks map[string]interface{} `json:"social_links"`
CreatedAt time.Time `json:"created_at"`
IsPublic bool `json:"is_public"`
CreatedAt time.Time `json:"created_at"`
}
// UserStats est maintenant défini dans internal/types/stats.go
@ -215,6 +216,7 @@ func (s *UserService) GetProfile(userID uuid.UUID, requesterID *uuid.UUID) (*Pro
profile.Location = nil
profile.Birthdate = nil
profile.Gender = nil
profile.SocialLinks = nil
}
// Cache the profile
@ -301,6 +303,9 @@ func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileReq
if req.BannerURL != nil {
updates["banner_url"] = *req.BannerURL
}
if req.IsPublic != nil {
updates["is_public"] = *req.IsPublic
}
// Apply updates to user object
if firstname, ok := updates["first_name"].(string); ok {
@ -338,6 +343,9 @@ func (s *UserService) UpdateProfile(userID uuid.UUID, req types.UpdateProfileReq
if bannerURL, ok := updates["banner_url"].(string); ok {
user.BannerURL = bannerURL
}
if isPublic, ok := updates["is_public"].(bool); ok {
user.IsPublic = isPublic
}
// Save changes
err = s.userRepo.Update(user)
@ -400,6 +408,7 @@ func (s *UserService) userToProfile(user *models.User) *Profile {
Birthdate: birthdate,
Gender: gender,
SocialLinks: socialLinks,
IsPublic: user.IsPublic,
CreatedAt: user.CreatedAt,
}
}

View file

@ -16,6 +16,7 @@ type UpdateProfileRequest struct {
BannerURL *string `json:"banner_url"`
WebsiteURL *string `json:"website_url"`
ProfilePrivacy *string `json:"profile_privacy"`
IsPublic *bool `json:"is_public"`
}
// UserSettingsResponse represents user settings