diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..8fe24f1 --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { useSession } from 'next-auth/react' +import { redirect } from 'next/navigation' +import type { UserProfile } from '@/lib/api-schema' +import ProfileForm from '@/components/profile/ProfileForm' + +const fetchProfile = async (): Promise => { + const res = await fetch('/api/profile') + if (!res.ok) throw new Error('Failed to fetch profile') + return res.json() +} + +export default function ProfilePage() { + // Protect the route + const { status } = useSession({ + required: true, + onUnauthenticated() { + redirect('/') + }, + }) + + const { data: profile, isLoading } = useQuery({ + queryKey: ['profile'], + queryFn: fetchProfile, + }) + + if (status === 'loading' || isLoading) { + return ( +
+ Loading Profile... +
+ ) + } + + return ( +
+

Edit Your Profile

+ {profile && } +
+ ) +} diff --git a/components/charts/CompatibilityChart.tsx b/components/charts/CompatibilityChart.tsx new file mode 100644 index 0000000..4cfa3ab --- /dev/null +++ b/components/charts/CompatibilityChart.tsx @@ -0,0 +1,127 @@ +'use client' + +import type { CompatibilityDetails } from '@/lib/api-schema' +import { Group } from '@visx/group' +import { scaleLinear, scalePoint } from '@visx/scale' +import { Line, Polygon } from '@visx/shape' +import { Text } from '@visx/text' +import { motion } from 'framer-motion' +import { useMemo } from 'react' + +interface CompatibilityChartProps { + details: CompatibilityDetails + width: number + height: number +} + +// Helper function to convert polar coordinates to cartesian +function polarToCartesian(angle: number, radius: number) { + return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) } +} + +export default function CompatibilityChart({ + details, + width, + height, +}: CompatibilityChartProps) { + const data = useMemo( + () => [ + { label: 'Genre', score: details.genre.score }, + { label: 'Era', score: details.era.score }, + { label: 'Artist', score: details.artist.score }, + { label: 'Obscurity', score: details.obscurity.score }, + ], + [details] + ) + + const margin = { top: 60, right: 60, bottom: 60, left: 60 } + const centerX = width / 2 + const centerY = height / 2 + const radius = + Math.min(width, height) / 2 - Math.max(...Object.values(margin)) + + // Define scales + const angleScale = scalePoint({ + domain: data.map((d) => d.label), + range: [0, 2 * Math.PI], + }) + + const radiusScale = scaleLinear({ + domain: [0, 100], + range: [0, radius], + }) + + // Generate points for the polygon + const polygonPoints = data.map((d) => { + const angle = angleScale(d.label)! - Math.PI / 2 + const r = radiusScale(d.score) + const { x, y } = polarToCartesian(angle, r) + return { x: centerX + x, y: centerY + y } + }) + + const pointsString = polygonPoints.map((p) => `${p.x},${p.y}`).join(' ') + + // Levels for the radar grid + const levels = [25, 50, 75, 100] + + return ( + + + {/* Radar grid lines */} + {levels.map((level, i) => ( + + ))} + {/* Axis lines and labels */} + {data.map((d, i) => { + const angle = angleScale(d.label)! - Math.PI / 2 + const point = polarToCartesian(angle, radius + 10) + return ( + + + + {d.label} + + + ) + })} + {/* Data polygon with animation */} + `${centerX},${centerY}`).join(' '), + }} + animate={{ + points: pointsString, + }} + transition={{ + duration: 0.8, + ease: 'easeInOut', + }} + /> + + + ) +} diff --git a/components/profile/ProfileForm.tsx b/components/profile/ProfileForm.tsx new file mode 100644 index 0000000..309fbfb --- /dev/null +++ b/components/profile/ProfileForm.tsx @@ -0,0 +1,109 @@ +'use client' + +import type { UserProfile } from '@/lib/api-schema' +import { profileFormSchema, type ProfileFormValues } from '@/lib/validators' +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' + +// This is our API function for the mutation +const updateProfile = async (data: ProfileFormValues): Promise => { + const res = await fetch('/api/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error('Server error: Failed to update profile.') + return res.json() +} + +interface ProfileFormProps { + profile: UserProfile +} + +export default function ProfileForm({ profile }: ProfileFormProps) { + const queryClient = useQueryClient() + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(profileFormSchema), + defaultValues: { + bio: profile.bio || '', + }, + }) + + const mutation = useMutation({ + mutationFn: updateProfile, + // --- The Optimistic Update Logic --- + onMutate: async (newProfileData) => { + // 1. Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['profile'] }) + + // 2. Snapshot the previous value + const previousProfile = queryClient.getQueryData(['profile']) + + // 3. Optimistically update to the new value + queryClient.setQueryData(['profile'], (old) => + old ? { ...old, ...newProfileData } : undefined + ) + + // 4. Return a context object with the snapshotted value + return { previousProfile } + }, + // If the mutation fails, use the context returned from onMutate to roll back + onError: (err, newProfile, context) => { + if (context?.previousProfile) { + queryClient.setQueryData(['profile'], context.previousProfile) + // You can also show a toast notification here + alert('Update failed! Your changes have been reverted.') + } + }, + // Always refetch after error or success to ensure server state + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['profile'] }) + }, + }) + + const onSubmit = (data: ProfileFormValues) => { + mutation.mutate(data) + } + + // We get the latest profile data directly from the query cache + const currentProfile = queryClient.getQueryData(['profile']) + + return ( +
+
+ +