Skip to content
Snippets Groups Projects
Commit 5b0c7a6c authored by Yusuf Akgül's avatar Yusuf Akgül :hatching_chick:
Browse files

profile ui and editing, needs still optimization

parent a5e43b4e
No related branches found
No related tags found
1 merge request!38Profile
Pipeline #39542 passed
Showing
with 339 additions and 323 deletions
import { GlobalLayout } from "@/components/global-layout"
import { UserAvatar } from "@/components/user-avatar"
import { db } from "@/lib/db"
import { User } from "@prisma/client"
import Link from "next/link"
export default async function UserFollowers({ params }: { params: { userid: string } }) {
const fullUser = await db.user.findFirst({
where: {
username: params.userid
},
include: {
following: true,
followers: true
}
})
const followers = await db.user.findMany({
where: {
following: {
every: {
followerId: { not: undefined }
}
},
followers: {
every: {
followingId: fullUser?.id
}
}
}
})
return (
<GlobalLayout
mainContent={
<div className="flex flex-col w-full">
{followers?.map((follower: User) => (
<div className="flex items-center space-y-6" key={follower.id}>
<Link href={`/${follower.username}`} className="flex flex-row">
<UserAvatar
user={{ username: follower.username, image: follower.image }}
className="h-10 w-10"
/>
<div className="flex flex-col ml-3">
<span className="font-bold">{follower.name}</span>
<span className="text-sky-500 text-sm">@{follower.username}</span>
</div>
</Link>
<div className="ml-auto">
{/* Followbutton */}
</div>
</div>
))}
</div>
}
/>
)
}
\ No newline at end of file
export default async function Following() {
return (
<div>
<h1>Following Page WIP</h1>
</div>
)
}
\ No newline at end of file
import { GlobalLayout } from "@/components/global-layout"
import { Profile } from "@/components/profile/components/profile"
import { ProfileSideContent } from "@/components/profile/components/profile-side-content"
import { ProfileUserInfo } from "@/components/profile/components/profile-user-info"
import { Card } from "@/components/ui/card"
import { UserNotFound } from "@/components/user-not-found"
import getURL from "@/lib/utils"
export const dynamic = 'force-dynamic'
export const fetchCache = 'force-no-store'
export default async function ProfileLayout({
params,
children,
}: {
params: { username: string }
children: React.ReactNode
}) {
const user = await fetch(getURL(`/api/users/${params.username}`)).then((result) => result.json())
return (
<GlobalLayout
mainContent={
<Profile >
{children}
</Profile>
<Card className="overflow-hidden h-full w-full">
{!user ?
<UserNotFound />
:
<>
<ProfileUserInfo user={user} />
{children}
</>
}
</Card>
}
sideContent={<ProfileSideContent />}
/>
......
import { ProfileUserContent } from "@/components/profile/components/profile-user-content"
export default async function User({ params }: { params: { userid: string } }) {
export default async function User({ params }: { params: { username: string } }) {
return (
<div className="space-y-6 w-full">
<ProfileUserContent userid={params.userid} />
{/* <ProfileUserContent userid={params.username} /> */}
</div>
)
}
\ No newline at end of file
......@@ -29,6 +29,7 @@ export async function GET(request: Request, context: { params: { username: strin
createdAt: true,
bio: true,
location: true,
website: true,
followers: true,
following: true,
......
"use client"
import { useFollow } from "./profile/hooks/use-follow"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"
......
......@@ -20,41 +20,52 @@ import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/use-toast"
import { UserAvatar } from "@/components/user-avatar"
import { zodResolver } from "@hookform/resolvers/zod"
import { useSession } from "next-auth/react"
import Image from "next/image"
import { useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { useUpdateProfile } from "../hooks/use-update-profile"
import { IProfile, IUser } from "../types"
const FormSchema = z.object({
name: z.string().min(1, "Name can't be blank.").max(50, "Name can't be more than 50 characters"),
name: z.string().min(1, "Name can't be blank").max(50, "Name can't be more than 50 characters"),
bio: z.string().max(160, "Bio can't be more than 160 characters").optional(),
location: z.string().max(30, "Location can't be more than 30 characters").optional(),
website: z.string().max(100, "Website can't be more than 100 characters").optional(),
})
const ImagesSchema = z.custom<File>().refine((file) => file instanceof File, { message: "Expected a file" })
.refine((file) => file?.size < 4000000, { message: "Images must be less than 4MB" })
.optional()
export const EditProfileModal = ({ user }: { user: IUser }) => {
const { isLoading, mutate } = useUpdateProfile()
const { isLoading, isSuccess, mutate } = useUpdateProfile()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: user.name,
bio: user.bio || "",
location: user.location || "",
website: user.website || "",
},
})
form.setValue('name', user.name)
form.setValue('bio', user.bio || "")
form.setValue('location', user.location || "")
form.setValue('website', user.website || "")
const [chosenImages, setChosenImages] = useState<Partial<IProfile>>({
const [open, setOpen] = useState(false)
const [imageErrorMessage, setImageErrorMessage] = useState("")
const [chosenBanner, setChosenBanner] = useState<Partial<IProfile>>({
banner: { url: user?.banner || "", file: undefined },
})
const [chosenImages, setChosenImages] = useState<Partial<IProfile>>({
image: { url: user?.image || "", file: undefined },
})
const bannerInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
async function onSave(formData: z.infer<typeof FormSchema>) {
if (!user) return null
......@@ -63,36 +74,56 @@ export const EditProfileModal = ({ user }: { user: IUser }) => {
bio: formData.bio,
location: formData.location,
website: formData.website,
banner: { url: user?.banner || "", file: chosenImages.banner?.file },
banner: { url: user?.banner || "", file: chosenBanner.banner?.file },
image: { url: user?.image || "", file: chosenImages.image?.file },
}
// TODO dont send everything, only send what has changed
mutate({
profile,
userId: user.id,
})
toast({
description: "Successfully updated profile.",
})
}
useEffect(() => {
if (isSuccess) {
toast({
description: "Successfully updated profile.",
})
setOpen(false)
router.refresh()
}
}, [isSuccess, router])
const chooseImage = async (event: any, type: string) => {
const file = event.target.files[0]
if (!file) return
try {
ImagesSchema.parse(file)
setImageErrorMessage("")
} catch (error: any) {
const err = error as z.ZodError
setImageErrorMessage(err.issues[0].message)
return
}
if (type === "banner" && bannerInputRef.current)
const reader = new FileReader()
if (type === "banner" && bannerInputRef.current) {
bannerInputRef.current.value = ""
reader.onloadend = () => {
setChosenBanner({
["banner"]: { url: reader.result as string, file },
})
}
}
if (type === "avatar" && imageInputRef.current)
if (type === "image" && imageInputRef.current) {
imageInputRef.current.value = ""
const reader = new FileReader()
reader.onloadend = () => {
setChosenImages({
[type]: { url: reader.result as string, file },
})
reader.onloadend = () => {
setChosenImages({
["image"]: { url: reader.result as string, file },
})
}
}
reader.readAsDataURL(file)
......@@ -101,7 +132,7 @@ export const EditProfileModal = ({ user }: { user: IUser }) => {
if (!user) return null
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
......@@ -113,156 +144,191 @@ export const EditProfileModal = ({ user }: { user: IUser }) => {
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="grid gap-3 py-3">
<Card className="relative w-full overflow-hidden">
<AspectRatio ratio={889 / 500} className="bg-slate-600 dark:bg-slate-400">
{chosenImages.banner?.url &&
<Image
src={chosenImages.banner?.url}
alt={"user banner image"}
fill
priority
className="object-center"
/>
<form onSubmit={form.handleSubmit(onSave)}>
<div className="grid gap-3 py-3">
<div className="space-y-2">
<div className="grid grid-cols-4 items-center gap-4">
<div className="col-span-1 relative w-full overflow-hidden items-center">
<div className="object-center object-cover w-full h-full">
{!chosenImages.image?.url ?
<UserAvatar
user={{ username: user.username, image: null }}
className="object-center object-cover w-full h-full aspect-square"
/>
:
<AspectRatio ratio={1 / 1} className="overflow-hidden rounded-full">
<Image
src={chosenImages.image?.url}
alt={"user image"}
fill
priority
className="object-center object-cover w-full h-full "
/>
</AspectRatio>
}
</div>
<input
className="hidden resize-none"
type="file"
accept="image/*"
ref={imageInputRef}
onChange={(e) => chooseImage(e, "image")}
disabled={isLoading}
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex space-x-3">
<Button
type="button"
variant="outline"
size="icon"
className="bg-opacity-50 dark:bg-opacity-50"
onClick={() => imageInputRef?.current?.click()}
disabled={isLoading}
>
<Icons.camera />
</Button>
</div>
</div>
<Card className="col-span-3 relative w-full overflow-hidden border-none">
<AspectRatio ratio={3 / 1} className="bg-muted overflow-hidden">
{chosenBanner.banner?.url &&
<Image
src={chosenBanner.banner?.url}
alt={"user banner image"}
fill
priority
className="object-center object-cover w-full h-full"
/>
}
</AspectRatio>
<input
className="hidden w-full resize-none"
type="file"
accept="image/*"
ref={bannerInputRef}
onChange={(e) => chooseImage(e, "banner")}
disabled={isLoading}
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex space-x-3">
<Button
type="button"
variant="outline"
size="icon"
className="bg-opacity-50 dark:bg-opacity-50"
onClick={() => bannerInputRef.current?.click()}
disabled={isLoading}
>
<Icons.camera />
</Button>
{chosenBanner.banner?.url && (
<Button
type="button"
variant="outline"
size="icon"
className="bg-opacity-50 dark:bg-opacity-50"
onClick={() => setChosenBanner({ banner: { url: "", file: undefined } })}
disabled={isLoading}
>
<Icons.close />
</Button>
)}
</div>
</Card>
</div>
{imageErrorMessage &&
<div className="grid grid-cols-4 items-center gap-4">
<div className="col-span-1"></div>
<div className="col-span-3">
<p className="text-sm font-medium text-destructive">{imageErrorMessage}</p>
</div>
</div>
}
</AspectRatio>
<input
type="file"
accept="image/*"
ref={bannerInputRef}
onChange={(e) => chooseImage(e, "banner")}
disabled={isLoading}
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex space-x-3">
<Button
variant="outline"
size="icon"
className="bg-opacity-50 dark:bg-opacity-50"
onClick={() => bannerInputRef.current?.click()}
disabled={isLoading}
>
<Icons.camera />
</Button>
{chosenImages.banner?.url && (
<Button
variant="outline"
size="icon"
className="bg-opacity-50 dark:bg-opacity-50"
onClick={() => setChosenImages({ banner: { url: "", file: undefined } })}
disabled={isLoading}
>
<Icons.close />
</Button>
)}
</div>
</Card>
<div className="absolute w-24 h-24 -mt-12 overflow-hidden">
<UserAvatar
user={{ username: user.username, image: chosenImages.banner?.url || null }}
className="object-cover"
/>
<input
type="file"
accept="image/*"
ref={imageInputRef}
onChange={(e) => chooseImage(e, "image")}
disabled={isLoading}
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex space-x-3">
<Button
variant="outline"
size="icon"
className="bg-opacity-50 dark:bg-opacity-50"
onClick={() => imageInputRef?.current?.click()}
disabled={isLoading}
>
<Icons.camera />
</Button>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-3">
<FormControl>
<Input id="name" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input id="name" className="col-span-3" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="bio" className="text-right">
Bio
</Label>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem className="col-span-3">
<FormControl>
<Textarea id="bio" className="resize-none" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="bio" className="text-right">
Bio
</Label>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea id="bio" className="col-span-3 resize-none" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">
Location
</Label>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem className="col-span-3">
<FormControl>
<Input id="location" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">
Location
</Label>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormControl>
<Input id="location" className="col-span-3" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="website" className="text-right">
Website
</Label>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem className="col-span-3">
<FormControl>
<Input id="website" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="website" className="text-right">
Website
</Label>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormControl>
<Input id="website" className="col-span-3" disabled={isLoading} {...field} />
</FormControl>
<FormMessage />
</FormItem>
<DialogFooter>
<Button type="submit" disabled={isLoading || !form.formState.isDirty}>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
/>
</div>
Save changes
</Button>
</DialogFooter>
</form>
</Form>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading && (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
)}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
......
......@@ -13,71 +13,83 @@ import { UserJoinDate } from "./user-join-date"
export const ProfileUserInfo = async ({ user }: { user: IUser }) => {
const session = await getCurrentUser()
// const isFollowing = following({
// user,
// sessionUserId: session ? session.id : "",
// })
const isFollowing = following({
user,
sessionUserId: session ? session.id : "",
})
return (
<>
{/* <div className="h-64 overflow-hidden">
<AspectRatio ratio={889 / 500} className="bg-slate-600 dark:bg-slate-400">
{user.banner &&
<Image
src={user.banner}
alt={"user banner image"}
fill
priority
className="object-center"
/>
}
</AspectRatio>
<AspectRatio ratio={3 / 1} className="bg-muted w-full overflow-hidden">
{user.banner &&
<Image
src={user.banner}
alt={"user banner image"}
fill
priority
className="object-center object-cover w-full h-full"
/>
}
</AspectRatio>
<div className="relative">
<div className="flex items-end justify-between p-6 md:px-12">
<div>
<div className="absolute bottom-6 md:bottom-3">
<UserAvatar
user={{ username: user.username, image: user.image || null }}
className="h-20 md:h-40 w-20 md:w-40"
/>
</div>
</div>
<div>
{session?.id === user.id ? (
<EditProfileModal user={user} />
) : (
<FollowButton
userId={user.id}
username={user.username ? user.username : ""}
isFollowing={isFollowing}
/>
)}
</div>
</div>
</div>
<div className="p-6 md:p-12 ss:flex">
<UserAvatar
user={{ username: user.username, image: user.image || null }}
className="h-52 w-52 -mt-36"
/>
<div className="ml-6 md:ml-12 space-y-3">
<div className="px-6 md:px-12 flex flex-col space-y-3 w-full">
<div className="pb-3 whitespace-nowrap items-center">
<h1 className="text-2xl font-bold">{user.name}</h1>
<h1 className="text-md text-sky-500">@{user.username}</h1>
{user.bio && <h1 className="">{user.bio}</h1>}
</div>
{user.bio && <h1 className="">{user.bio}</h1>}
<div className="flex whitespace-nowrap items-center space-x-6 text-muted-foreground">
{user.location &&
<div className="space-x-2">
<Icons.location />
{user.location}
<div className="flex items-center">
<Icons.location className="mr-1" />
<div>{user.location}</div>
</div>
}
{user.website &&
<div className="space-x-2">
<Icons.website />
{user.website}
<div className="flex items-center">
<Icons.website className="mr-1" />
<div>{user.website}</div>
</div>
}
{user.createdAt && <UserJoinDate date={user.createdAt} />}
</div>
<div className="flex justify-end ml-6 md:ml-12">
{session?.id === user.id ? (
<EditProfileModal user={user} />
) : (
<FollowButton userId={user.id} username={user.username ? user.username : ""} isFollowing={isFollowing} />
)}
</div>
<div className="flex justify-between w-full mt-6 md:mt-12">
<div className="flex whitespace-nowrap items-center space-x-6">
<Link href={`/${user.username}/following`}>
<span className="text-sm">{user._count?.following}</span>
<span className="text-sm">Following</span>
<span className="text-sm font-bold">{user._count?.following}</span>
<span className="text-sm text-muted-foreground"> Following</span>
</Link>
<Link href={`/${user.username}/followers`}>
<span className="text-sm">{user._count?.followers}</span>
<span className="text-sm">Followers</span>
<span className="text-sm font-bold">{user._count?.followers}</span>
<span className="text-sm text-muted-foreground"> Followers</span>
</Link>
</div>
</div> */}
</div>
</ >
)
}
\ No newline at end of file
"use client"
import LoadingItem from "@/components/loading-item"
import { TryAgain } from "@/components/try-again"
import { Card } from "@/components/ui/card"
import { UserNotFound } from "@/components/user-not-found"
import { usePathname } from "next/navigation"
import { useUser } from "../hooks/use-user"
import { ProfileUserInfo } from "./profile-user-info"
export const Profile = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname()
const username = pathname?.split("/")[1] || ""
// TODO: Fix this client / server side rendering issue
const { data: user, isLoading, isError } = useUser(username)
if (isLoading) {
return (
<>
<LoadingItem />
</>
)
}
if (isError) {
return (
<>
<TryAgain />
</>
)
}
if (!isLoading && !isError && !user) {
return (
<>
<UserNotFound />
</>
)
}
return (
<Card className="overflow-hidden h-full w-full">
{/* <ProfileUserInfo user={user} /> */}
<div>{children}</div>
</Card>
)
}
......@@ -9,11 +9,11 @@ export const UserJoinDate = ({
showIcon?: boolean
}) => {
return (
<div className="">
{showIcon && (<Icons.calendar />)}
<span className="">
<div className="flex items-center">
{showIcon && (<Icons.calendar className="mr-1" />)}
<div>
Joined {dayjs(date).format("MMMM YYYY")}
</span>
</div>
</div>
)
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment