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:
parent
09f9dc3de6
commit
b33c9d3cca
10 changed files with 200 additions and 6 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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'est pas visible.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue