diff --git a/app/api/gweets/route.ts b/app/api/gweets/route.ts index 57c4d7cbe8f7317b82fe4671e0f52508bc4379dc..8178a2d05def1608c68afa3d4271ead59fc3218b 100644 --- a/app/api/gweets/route.ts +++ b/app/api/gweets/route.ts @@ -87,8 +87,8 @@ export async function GET(request: Request) { // create gweet export async function POST(request: Request) { - const gweet = await request.json(); - console.log(gweet) + const { gweet, fileprops } = await request.json(); + const gweetSchema = z .object({ content: z.string().min(1).max(280), @@ -98,13 +98,22 @@ export async function POST(request: Request) { }) .strict(); - const zod = gweetSchema.safeParse(gweet); + const zodGweet = gweetSchema.safeParse(gweet); - if (!zod.success) { + const mediaSchema = z.array( + z.object({ + gweetId: z.string().nullable().optional(), + url: z.string(), + key: z.string(), + type: z.string(), + }).strict() + ); + + if (!zodGweet.success) { return NextResponse.json( { message: "Invalid request body", - error: zod.error.formErrors, + error: zodGweet.error.formErrors, }, { status: 400 }, ); } @@ -116,8 +125,37 @@ export async function POST(request: Request) { }, }); + if (fileprops.length > 0) { + const mediaArray = fileprops.map((fileprop: { fileUrl: string; fileKey: string; }) => { + const media = { + gweetId: created_gweet.id, + url: fileprop.fileUrl, + key: fileprop.fileKey, + type: "IMAGE", + } + + return media; + }); + + const zodMedia = mediaSchema.safeParse(mediaArray); + + if (!zodMedia.success) { + return NextResponse.json( + { + message: "Invalid media body", + error: zodMedia.error.formErrors, + }, { status: 400 }, + ); + } + + await db.media.createMany({ + data: mediaArray, + }); + } + return NextResponse.json(created_gweet, { status: 200 }); } catch (error: any) { + console.log(error); return NextResponse.json( { message: "Something went wrong", diff --git a/app/api/media/route.ts b/app/api/media/route.ts deleted file mode 100644 index 3f98d377a5c639772d8a82a1997f73442a4ce247..0000000000000000000000000000000000000000 --- a/app/api/media/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; - -import { db } from "@/lib/db"; - -export async function POST(request: Request) { - const { media } = await request.json(); - - const mediaSchema = z - .object({ - gweetId: z.string().nullable().optional(), - url: z.string(), - key: z.string(), - type: z.string(), - }) - .strict(); - - const zod = mediaSchema.safeParse(media); - - if (!zod.success) { - return NextResponse.json({ error: zod.error }, { status: 400 }); - } - - try { - await db.media.create({ - data: { - ...media, - }, - }); - return NextResponse.json({ message: "Media created successfully" }, { status: 200 }); - } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} \ No newline at end of file diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts index ddd9152dc3ba66b282bf273f0c985b5591961ed1..310010683f2bbfca79961f9e0995e1a0a0b6722c 100644 --- a/app/api/uploadthing/core.ts +++ b/app/api/uploadthing/core.ts @@ -4,7 +4,7 @@ import { createUploadthing, type FileRouter } from "uploadthing/next"; const f = createUploadthing(); export const ourFileRouter = { - imageUploader: f({ image: { maxFileSize: "4MB" } }) + imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 4 } }) .middleware(async ({ req }) => { const user = await getCurrentUser(); @@ -12,11 +12,7 @@ export const ourFileRouter = { return { userId: user.id }; }) - .onUploadComplete(async ({ metadata, file }) => { - console.log("Upload complete for userId:", metadata.userId); - - console.log("file url", file.url); - }), + .onUploadComplete(async ({ metadata, file }) => { }), } satisfies FileRouter; export type OurFileRouter = typeof ourFileRouter; \ No newline at end of file diff --git a/components/create-gweet/api/post-gweet.ts b/components/create-gweet/api/post-gweet.ts index 13ef6fa2dba409d9d55e2ef85ad8362c61848c3f..32d7f2e1c1549a139dd3d279a2ef5569b584cd6c 100644 --- a/components/create-gweet/api/post-gweet.ts +++ b/components/create-gweet/api/post-gweet.ts @@ -22,30 +22,16 @@ export const postGweet = async ({ }; try { + let fileprops: { fileUrl: string; fileKey: string; }[] = []; + if (files.length > 0) { + fileprops = await uploadFiles({ files, endpoint: 'imageUploader' }) + } + const data = await fetch('/api/gweets', { method: 'POST', - body: JSON.stringify(gweet) + body: JSON.stringify({ gweet, fileprops }) }).then((result) => result.json()) - if (files.length > 0) { - const gweet_id = data.id; - files.forEach(async (file) => { - const [res] = await uploadFiles({ files: [file], endpoint: 'imageUploader' }) - - const media = { - ...(gweet_id && { gweet_id }), - url: res.fileUrl, - key: res.fileKey, - type: "image", - }; - - await fetch('/api/media', { - method: 'POST', - body: JSON.stringify(media) - }) - }) - } - const hashtags = retrieveHashtagsFromGweet(content); if (hashtags) await postHashtags(hashtags); diff --git a/components/create-gweet/components/create-gweet-wrapper.tsx b/components/create-gweet/components/create-gweet-wrapper.tsx index 1a6cb12190b091cb91dab0ac9ac80c06df1f2c8b..b0572279f1fb0f6375a14032dffbde4904fc81e9 100644 --- a/components/create-gweet/components/create-gweet-wrapper.tsx +++ b/components/create-gweet/components/create-gweet-wrapper.tsx @@ -12,7 +12,7 @@ export const CreateGweetWrapper = ({ const [isComment, setIsComment] = useState(true); return ( - <div className=""> + <div className="px-6"> <CreateGweet replyToGweetId={replyToGweetId} placeholder="Gweet your reply..." diff --git a/components/create-gweet/components/create-gweet.tsx b/components/create-gweet/components/create-gweet.tsx index 4049658cf1ef381d03396d0657c693c4b0a00d0d..ff8ada16eef4a4065b541bb39348479b58c193a9 100644 --- a/components/create-gweet/components/create-gweet.tsx +++ b/components/create-gweet/components/create-gweet.tsx @@ -31,8 +31,8 @@ import { IChosenImages } from "../types"; const FormSchema = z.object({ gweet: z .string() - .min(1, { message: "Come on post something...", }) - .max(1000, { message: "Gweets cannot be more that 1000 characters.", }), + .min(1, { message: "Come on post something..." }) + .max(240, { message: "Gweets cannot be more that 240 characters." }), }) export const CreateGweet = ({ @@ -48,27 +48,24 @@ export const CreateGweet = ({ placeholder?: string | null; isComment?: boolean; }) => { - const [isGweetLoading, setIsGweetLoading] = useState<boolean>(false); const [chosenImages, setChosenImages] = useState<IChosenImages[]>([]); const imageUploadRef = useRef<HTMLInputElement>(null); const { data: session } = useSession(); - const mutation = useCreateGweet(); + const { isLoading, mutate, data } = useCreateGweet(); const form = useForm<z.infer<typeof FormSchema>>({ resolver: zodResolver(FormSchema), }) - function onGweet(data: z.infer<typeof FormSchema>) { + async function onGweet(formData: z.infer<typeof FormSchema>) { if (!session) return null; - setIsGweetLoading(true); - - mutation.mutate({ - content: data.gweet, - files: [], + mutate({ + content: formData.gweet, authorId: session?.user?.id, replyToGweetId, + files: chosenImages.map((image) => image.file), quoteGweetId: quoted_gweet?.id || null, }) @@ -76,28 +73,44 @@ export const CreateGweet = ({ description: "Your gweet was send.", }) - setIsGweetLoading(false); form.setValue('gweet', ''); + setChosenImages([]); } - const chooseImage = async ( + const chooseImages = async ( event: React.ChangeEvent<HTMLInputElement>, setChosenImages: (images: IChosenImages[]) => void, ) => { - const file = event?.target?.files?.[0]; - - if (file) { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - setChosenImages([ - ...chosenImages, - { + const files = event.target.files; + + if (files && files.length > 0) { + const newImages: IChosenImages[] = []; + const totalSelectedImages = chosenImages.length + files.length; + + if (totalSelectedImages > 4) { + return toast({ + variant: "destructive", + description: "You can only upload 4 images per gweet.", + }) + } + + for (let i = 0; i < files.length; i++) { + const filePath = files[i]; + + const reader = new FileReader(); + reader.readAsDataURL(filePath); + + reader.onload = () => { + newImages.push({ url: reader.result, - file: file, - }, - ]); - }; + file: filePath, + }); + + if (newImages.length === files.length) { + setChosenImages([...chosenImages, ...newImages]); + } + }; + } } }; @@ -105,17 +118,18 @@ export const CreateGweet = ({ return ( <> - <div className="grid grid-cols-2 gap-11 p-4"> - {parent_gweet && ( + {/* TODO showing if is replying */} + {parent_gweet && ( + <div className="grid grid-cols-2 gap-11 p-4"> + <div className="grid place-items-center grid-rows-2 gap-1"> <UserAvatar user={{ username: parent_gweet?.author?.username, image: parent_gweet?.author?.image || null }} /> <div className="bg-gray-300 h-full w-px"></div> </div> - )} - <div className="flex flex-col"> - {parent_gweet && ( + + <div className="flex flex-col"> <> <div className="flex gap-1"> <span className="text-secondary text-sm font-medium truncate hover:underline"> @@ -132,107 +146,117 @@ export const CreateGweet = ({ )} </div> </> - )} - - {parent_gweet && !isComment && ( - <div className={`${!parent_gweet ? 'ml-16' : ''} flex items-center gap-1 cursor-pointer`}> - <span className="text-tertiary truncate">Replying to</span> - <span className="text-primary truncate"> - @{parent_gweet?.authorId} - </span> - </div> - )} + + {!isComment && ( + <div className={`${!parent_gweet ? 'ml-16' : ''} flex items-center gap-1 cursor-pointer`}> + <span className="text-tertiary truncate">Replying to</span> + <span className="text-primary truncate"> + @{parent_gweet?.authorId} + </span> + </div> + )} + </div> </div> - </div> + )} <div className="relative"> <Form {...form}> <form onSubmit={form.handleSubmit(onGweet)} className="space-y-6"> - <Card className="p-3"> - <FormField - control={form.control} - name="gweet" - render={({ field }) => ( - <FormItem className="flex items-start"> - <UserAvatar - className="mr-3" - user={{ username: session?.user.username || null, image: session?.user?.image || null }} - /> - <div className="flex-grow"> + <Card className="p-3 flex items-start relative"> + <UserAvatar + className="mr-3" + user={{ username: session?.user.username || null, image: session?.user?.image || null }} + /> + <Button + type="button" + variant="ghost" + size="icon" + className="absolute bottom-0 left-0 mb-3 ml-3" + onClick={() => imageUploadRef.current?.click()} + disabled={isLoading || !session.user} + > + <Icons.media className="text-muted-foreground" /> + </Button> + <div className="flex-grow"> + <FormField + control={form.control} + name="gweet" + render={({ field }) => ( + <FormItem> <FormControl> <Textarea placeholder={placeholder || "What's on your mind?"} - className="resize-none" - disabled={isGweetLoading || !session.user} + className="resize-none min-h-[100px]" + disabled={isLoading || !session.user} {...field} /> </FormControl> {!isComment ? - <FormDescription className="pt-3"> + <FormDescription> Your gweets will be public, and everyone can see them. </FormDescription> : null } <FormMessage /> + </FormItem> + )} + /> - <input - className="hidden w-full" - type="file" - onChange={(e) => chooseImage(e, setChosenImages)} - ref={imageUploadRef} - /> - {chosenImages.length > 0 && ( - <div className={`grid object-cover h-[680px] - ${chosenImages.length === 1 ? "grid-cols-1" - : chosenImages.length === 2 ? "grid-cols-2 gap-3" - : chosenImages.length === 3 || 4 ? "grid-cols-2 grid-rows-2 gap-3" - : "" - }`} - > - {chosenImages.map((image, i) => { - const isFirstImage = chosenImages.length === 3 && i === 0; - return ( - <Card key={i} className={`relative max-h-[680px] overflow-hidden ${isFirstImage ? "row-span-2" : ""}`}> - <Button - size="icon" - variant="secondary" - className="rounded-full absolute top-1 right-1 z-50" - onClick={() => { - setChosenImages( - chosenImages.filter((img, j) => j !== i), - ); - }} - > - <Icons.close /> - </Button> - <Image - src={image.url as string} - alt="gweet image" - fill - className="object-cover rounded-lg" - /> - </Card> - ); - })} - </div> - )} - {/* {quoted_gweet && <QuotedGweet gweet={quoted_gweet} />} */} - </div> - </FormItem> + <input + className="hidden w-full resize-none" + type="file" + multiple + onChange={(e) => chooseImages(e, setChosenImages)} + ref={imageUploadRef} + disabled={isLoading || !session.user} + /> + {chosenImages.length > 0 && ( + <div className={`grid object-cover h-[600px] pt-2 + ${chosenImages.length === 1 ? "grid-cols-1" + : chosenImages.length === 2 ? "grid-cols-2 gap-3" + : chosenImages.length === 3 || 4 ? "grid-cols-2 grid-rows-2 gap-3" + : "" + }`} + > + {chosenImages.map((image, i) => { + const isFirstImage = chosenImages.length === 3 && i === 0; + return ( + <Card key={i} className={`relative max-h-[600px] overflow-hidden ${isFirstImage ? "row-span-2" : ""}`}> + <Button + type="button" + size="icon" + variant="secondary" + className="rounded-full absolute top-1 right-1 z-50" + onClick={() => { + setChosenImages( + chosenImages.filter((img, j) => j !== i), + ); + }} + > + <Icons.close className="w-6 h-6" /> + </Button> + <Image + src={image.url as string} + alt="gweet image" + fill + className="object-cover rounded-lg" + /> + </Card> + ); + })} + </div> )} - /> - <Button - variant="ghost" - size="sm" - className="absolute bottom-3 right-3" - onClick={() => imageUploadRef.current?.click()} - disabled={isGweetLoading || !session.user} - /> + {/* {quoted_gweet && <QuotedGweet gweet={quoted_gweet} />} */} + </div> </Card> <div className="flex justify-end"> - <Button type="submit" size="lg" disabled={isGweetLoading || !session.user}> - {isComment ? 'Reply' : 'Gweet'} + <Button type="submit" size="lg" className=" w-20" disabled={isLoading || !session.user}> + {isLoading ? ( + <Icons.spinner className="h-4 w-4 animate-spin" /> + ) : ( + isComment ? 'Reply' : 'Gweet' + )} </Button> </div> </form> diff --git a/components/create-gweet/hooks/use-create-gweet.ts b/components/create-gweet/hooks/use-create-gweet.ts index 43ba0510ccdc4fe370dd214d3acad91840d1d60e..686d29a3ab113de076555c96fa58a398f46ccc8a 100644 --- a/components/create-gweet/hooks/use-create-gweet.ts +++ b/components/create-gweet/hooks/use-create-gweet.ts @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; - import { postGweet } from "../api/post-gweet"; export const useCreateGweet = () => { @@ -13,7 +12,6 @@ export const useCreateGweet = () => { authorId, replyToGweetId, quoteGweetId, - }: { content: string; files: File[]; diff --git a/components/gweets/components/actions/comment-button.tsx b/components/gweets/components/actions/comment-button.tsx index 7797c74c174e5e529fa5637351b89b6a35ef02e3..d7103bb5aa970784b94c846c0b860770e9606148 100644 --- a/components/gweets/components/actions/comment-button.tsx +++ b/components/gweets/components/actions/comment-button.tsx @@ -3,19 +3,16 @@ import { IGweet } from "../../types"; import { Icons } from "@/components/icons"; -export const CommentButton = ({ - gweet, -}: { - gweet: IGweet; -}) => { +export const CommentButton = ({ gweet }: { gweet: IGweet }) => { return ( - <Button variant="ghost" size="lg" className="px-6 py-3 hover:bg-sky-800"> - <Icons.messagecircle className="h-5 w-5" /> - <span className="pl-2"> - {gweet?.allComments?.length > 0 && ( - <span className="">{gweet?.allComments?.length}</span> - )} - </span> - </Button> + <div className="relative inline-flex items-center"> + <Button variant="ghost" size="icon" className="hover:bg-sky-800 z-50"> + <Icons.messagecircle className="w-6 h-6" /> + </Button> + + {gweet.allComments.length > 0 && ( + <span className="absolute pl-12">{gweet.allComments.length}</span> + )} + </div> ); }; \ No newline at end of file diff --git a/components/gweets/components/actions/like-button.tsx b/components/gweets/components/actions/like-button.tsx index be6a3a47b4ba642b903b7f05e966a7c3821590cf..46c17791be726f6372a22684432cec0b59f4aaf9 100644 --- a/components/gweets/components/actions/like-button.tsx +++ b/components/gweets/components/actions/like-button.tsx @@ -5,43 +5,37 @@ import { Button } from "@/components/ui/button"; import { useLike } from "../../hooks/use-like"; import { IGweet } from "../../types"; -export const LikeButton = ({ - gweet, -}: { - gweet?: IGweet; - smallIcons?: boolean; -}) => { +export const LikeButton = ({ gweet }: { gweet: IGweet }) => { const { data: session } = useSession(); - const hasLiked = gweet?.likes?.some( - (like) => like.userId === session?.user?.id, + const hasLiked = gweet.likes.some( + (like) => like.userId === session?.user.id, ); const mutation = useLike({ - gweetAuthorId: gweet?.author?.id, - sessionOwnerId: session?.user?.id, + gweetAuthorId: gweet.author.id, + sessionOwnerId: session?.user.id, }); return ( - <Button - onClick={(e) => { - e.stopPropagation(); - if (!session) { - return; + <div className="inline-flex items-center"> + <Button + onClick={(e) => { + e.stopPropagation(); + if (session) { + mutation.mutate({ gweetId: gweet.id, userId: session.user.id }); + } + }} + variant="ghost" size="icon" className="hover:bg-red-800" + > + {hasLiked ? + <Icons.heart className="w-6 h-6 fill-red-600 text-red-600" /> + : <Icons.heart className="w-6 h-6" /> } - mutation.mutate({ gweetId: gweet?.id, userId: session?.user?.id }); - }} - variant="ghost" size="lg" className="px-6 py-3 hover:bg-red-800" - > - {hasLiked ? - <Icons.heart className="h-5 w-5 fill-red-600 text-red-600" /> - : <Icons.heart className="h-5 w-5" /> - } + </Button> - <span className="pl-2"> - {gweet?.likes && gweet?.likes?.length > 0 && ( - <span className="">{gweet?.likes?.length}</span> - )} - </span> - </Button> + {gweet.likes.length > 0 && ( + <span className="px-2">{gweet?.likes?.length}</span> + )} + </div> ); }; \ No newline at end of file diff --git a/components/gweets/components/actions/regweet-button.tsx b/components/gweets/components/actions/regweet-button.tsx index 76c9ca3d5ef99f95bf8b533793390040647bb11f..ec5fbde683543bf38500e8c98f08b0baa37294f4 100644 --- a/components/gweets/components/actions/regweet-button.tsx +++ b/components/gweets/components/actions/regweet-button.tsx @@ -6,36 +6,35 @@ import { IGweet } from "../../types"; export const RegweetButton = ({ gweet }: { gweet: IGweet }) => { const { data: session } = useSession(); - const hasRegweeted = gweet?.regweets?.some( - (regweet) => regweet?.userId === session?.user?.id, + const hasRegweeted = gweet.regweets.some( + (regweet) => regweet.userId === session?.user?.id, ); const mutation = useRegweet(); return ( - <Button - onClick={(e) => { - e.stopPropagation(); - if (!session) { - return; // TODO: show login modal + <div className="relative inline-flex items-center"> + <Button + onClick={(e) => { + e.stopPropagation(); + if (session) { + mutation.mutate({ + gweetId: gweet.id, + userId: session.user.id, + }); + } + }} + variant="ghost" size="icon" className="hover:bg-green-800 z-50" + > + {hasRegweeted ? + <Icons.regweet className="w-6 h-6 text-green-600" /> + : <Icons.regweet className="w-6 h-6" /> } - mutation.mutate({ - gweetId: gweet?.id, - userId: session?.user?.id, - }); - }} - variant="ghost" size="lg" className="px-6 py-3 hover:bg-green-800" - > - {hasRegweeted ? - <Icons.regweet className="h-5 w-5 text-green-600" /> - : <Icons.regweet className="h-5 w-5" /> - } + </Button> - <span className="pl-2"> - {gweet && gweet?.regweets?.length > 0 && ( - <span className="">{gweet?.regweets?.length}</span> - )} - </span> - </Button> + {gweet.regweets.length > 0 && ( + <span className="absolute pl-12">{gweet.regweets.length}</span> + )} + </div> ); }; \ No newline at end of file diff --git a/components/gweets/components/delete-gweet-modal.tsx b/components/gweets/components/delete-gweet-modal.tsx index 6488b66d86391929a1af276939e9b9cdd4fd2ff0..83ae165e34f33902f6677c813cba5370b4ba94f5 100644 --- a/components/gweets/components/delete-gweet-modal.tsx +++ b/components/gweets/components/delete-gweet-modal.tsx @@ -4,6 +4,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { useDeleteGweet } from "../hooks/use-delete-gweet"; import { IGweet } from "../types"; +import { Icons } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,10 +15,14 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { useRouter } from "next/navigation"; export const DeleteGweetModal = ({ gweet, props, forwardedRef }: { gweet: IGweet, props: any, forwardedRef: any }) => { const { triggerChildren, onSelect, onOpenChange, ...itemProps } = props; - const mutation = useDeleteGweet(); + const { isLoading, mutate, isSuccess } = useDeleteGweet(); + + const router = useRouter(); + if (isSuccess) router.push("/home"); return ( <Dialog onOpenChange={onOpenChange}> @@ -41,12 +46,20 @@ export const DeleteGweetModal = ({ gweet, props, forwardedRef }: { gweet: IGweet </DialogDescription> </DialogHeader> <DialogFooter> - <Button type="submit" + <Button + variant="destructive" + type="submit" + disabled={isLoading} onClick={() => { - mutation.mutate({ + mutate({ gweetId: gweet?.id, }); - }}>Delete</Button> + }}> + {isLoading && ( + <Icons.spinner className="mr-2 h-4 w-4 animate-spin" /> + )} + Delete + </Button> </DialogFooter> </DialogContent> </Dialog> diff --git a/components/gweets/components/gweet-actions.tsx b/components/gweets/components/gweet-actions.tsx index 0d2d3bc921d7695d1dd6b40c9a66171b621d9e26..9011202799ca952df0d97d03f67c50ac86aff017 100644 --- a/components/gweets/components/gweet-actions.tsx +++ b/components/gweets/components/gweet-actions.tsx @@ -10,10 +10,10 @@ export const GweetActions = ({ gweet: IGweet; }) => { return ( - <div className="space-x-2"> + <div className="space-x-12 w-60"> <CommentButton gweet={gweet} /> <RegweetButton gweet={gweet} /> - <LikeButton gweet={gweet} smallIcons={false} /> + <LikeButton gweet={gweet} /> </div> ); }; \ No newline at end of file diff --git a/components/gweets/components/gweet-details.tsx b/components/gweets/components/gweet-details.tsx index c04e824830faf8617df87d58757109ffa416b447..f7e58328a84fd69aa93773dec2e68c1a51470f07 100644 --- a/components/gweets/components/gweet-details.tsx +++ b/components/gweets/components/gweet-details.tsx @@ -33,33 +33,34 @@ export const GweetDetails = () => { } return ( - <Card className="w-full h-full mt-12 p-2 xl:p-4 "> - <div className="flex flex-col space-y-2 p-3"> + <Card className="w-full h-full mt-12 p-3 xl:p-6 "> + <div className="flex flex-col space-y-3 px-3 pt-3"> <GweetAuthor gweet={gweet} /> {/* TODO needs handling of all gweets above and under the gweet */} - <div className="flex flex-col space-y-1"> - {gweet.content && <h1 className="mt-2">{gweet.content}</h1>} + <div className="flex flex-col space-y-3"> + {gweet.content && <h1 className="break-words">{gweet.content}</h1>} - {gweet.media && gweet.media.length > 0 && ( - <div className={`grid object-cover grid-cols- - ${gweet.media.length === 1 ? "1" - : gweet.media.length === 2 ? "2 space-x-3" - : gweet.media.length === 3 ? "3 space-x-3 space-y-3" - : gweet.media.length === 4 ? "4 space-x-3 space-y-3" - : "" + {/* TODO make own component */} + {gweet.media.length > 0 && ( + <div className={`grid object-cover h-[600px] ${gweet.media.length === 1 ? "grid-cols-1" + : gweet.media.length === 2 ? "grid-cols-2 gap-3" + : gweet.media.length === 3 || 4 ? "grid-cols-2 grid-rows-2 gap-3" + : "" }`} > - {gweet.media.slice(0, 4).map((media) => { + {gweet.media.map((image, i) => { + const isFirstImage = gweet.media.length === 3 && i === 0; return ( - <Image - key={media.id} - src={media.url} - alt={"image-" + media.id} - width={1000} - height={1000} - /> + <Card key={i} className={`relative max-h-[600px] overflow-hidden ${isFirstImage ? "row-span-2" : ""}`}> + <Image + src={image.url as string} + alt="gweet image" + fill + className="object-cover rounded-lg" + /> + </Card> ); })} </div> @@ -69,22 +70,18 @@ export const GweetDetails = () => { {/* {gweet?.quotedGweet && <QuotedGweet gweet={gweet?.quotedGweet} />} */} </div> - <div className="w-full"> - <div className="flex flex-col items-center flex-wrap md:flex-row space-y-3 md:space-y-0"> - <div className="flex-grow text-muted-foreground"> - <GweetCreationDate date={gweet.createdAt} /> - </div> - <div className="md:items-end"> - <GweetActions gweet={gweet} /> - </div> + <div className="flex flex-col items-center flex-wrap flex-grow sm:flex-row space-y-3 sm:space-y-0"> + <div className="flex-grow text-muted-foreground"> + <GweetCreationDate date={gweet.createdAt} /> + </div> + <div className="sm:items-end"> + <GweetActions gweet={gweet} /> </div> </div> - + <CreateGweetWrapper replyToGweetId={gweet?.id} /> </div> - - <CreateGweetWrapper replyToGweetId={gweet?.id} /> - <Separator className=" h-1 mb-3" /> + <Separator className="h-1 mb-3" /> <Comments gweetId={gweet?.id} /> </Card> ); diff --git a/components/gweets/components/gweet-options.tsx b/components/gweets/components/gweet-options.tsx index 8078ab598ae28234e4786557f147799c50d7ab7c..62af0a7d2b369ee927792ff1a9f2dbc50fc95a32 100644 --- a/components/gweets/components/gweet-options.tsx +++ b/components/gweets/components/gweet-options.tsx @@ -40,13 +40,19 @@ export const GweetOptions = ({ gweet }: { gweet: IGweet }) => { return ( <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon" ref={dropdownTriggerRef}> - <Icons.moreOptions /> - </Button> + <div className="h-5 flex items-center"> + <Button variant="ghost" size="option" ref={dropdownTriggerRef}> + <Icons.moreOptions /> + </Button> + </div> </DropdownMenuTrigger> <DropdownMenuContent - className="w-56 font-bold cursor-pointer" + align="end" + className="font-bold cursor-pointer" hidden={hasOpenDialog} + onClick={(event) => { + event.stopPropagation(); + }} onCloseAutoFocus={(event) => { if (focusRef.current) { focusRef.current.focus(); @@ -60,7 +66,7 @@ export const GweetOptions = ({ gweet }: { gweet: IGweet }) => { gweet={gweet} props={{ triggerChildren: ( - <div className="text-red-600"> + <div className="text-red-600 flex"> <Icons.trash className="mr-2 h-4 w-4 " /> <span>Delete</span> </div> diff --git a/components/gweets/components/gweet.tsx b/components/gweets/components/gweet.tsx index d966ee60c9f2855c650a69e3c67ef228d73b30d4..ff0b42fdb32a3b3261c94df785e3ccfdbbeda767 100644 --- a/components/gweets/components/gweet.tsx +++ b/components/gweets/components/gweet.tsx @@ -1,10 +1,12 @@ import { buttonVariants } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; import { UserAvatar } from "@/components/user-avatar"; import { cn, formatTimeElapsed } from "@/lib/utils"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { IGweet } from "../types"; import { GweetActions } from "./gweet-actions"; - +import { GweetOptions } from "./gweet-options"; export const Gweet = ({ gweet }: { gweet: IGweet }) => { const router = useRouter(); @@ -13,25 +15,10 @@ export const Gweet = ({ gweet }: { gweet: IGweet }) => { <div tabIndex={0} onClick={() => router.push(`/${gweet.author.username}/status/${gweet.id}`)} - className={cn(buttonVariants({ variant: "ghost" }), "flex h-auto w-full text-left cursor-pointer")} + className={cn(buttonVariants({ variant: "ghost" }), "flex flex-col flex-grow h-auto w-full text-left cursor-pointer items-start p-3 space-y-3")} > - <UserAvatar - user={{ username: gweet.author.username, image: gweet.author.image }} - className="h-10 w-10" - /> - <div className="ml-4 flex flex-col flex-grow"> - <div> - <div className="flex items-center"> - <h1 className="font-bold mr-2">{gweet.author.name}</h1> - <h1 className="text-sky-500 text-sm"> - @{gweet.author.username} - </h1> - <h1 className="text-gray-500 text-sm ml-auto"> - {formatTimeElapsed(gweet.createdAt)} - </h1> - </div> - - {/* <div className=""> + {/* TODO replyto */} + {/* <div className=""> {gweet?.replyToGweetId && ( <div className=""> <span className="">Replying to</span> @@ -47,15 +34,66 @@ export const Gweet = ({ gweet }: { gweet: IGweet }) => { </div> )} </div> */} + <div className="flex flex-row h-auto w-full"> + <UserAvatar + user={{ username: gweet.author.username, image: gweet.author.image }} + className="h-10 w-10" + /> - {gweet?.content && ( - <h1>{gweet.content}</h1> - )} - </div> - <div className="flex justify-end" > - <GweetActions gweet={gweet} /> + <div className="flex flex-col flex-grow space-y-3 ml-3 w-1"> + <div className="flex items-start"> + + <div className="flex space-x-2 flex-grow"> + <h1 className="font-bold">{gweet.author.name}</h1> + <h1 className="text-sky-500 text-sm"> + @{gweet.author.username} + </h1> + <span>·</span> + <h1 className="text-gray-500 text-sm"> + {formatTimeElapsed(gweet.createdAt)} + </h1> + </div> + + <div className="ml-auto"> + <GweetOptions gweet={gweet} /> + </div> + </div> + + <div className="flex flex-col flex-grow space-y-3 w-full"> + {gweet.content && <h1 className="break-words">{gweet.content}</h1>} + + {gweet.media.length > 0 && ( + <div className={`grid object-cover h-[600px] ${gweet.media.length === 1 ? "grid-cols-1" + : gweet.media.length === 2 ? "grid-cols-2 gap-3" + : gweet.media.length === 3 || 4 ? "grid-cols-2 grid-rows-2 gap-3" + : "" + }`} + > + {gweet.media.map((image, i) => { + const isFirstImage = gweet.media.length === 3 && i === 0; + return ( + <Card key={i} className={`relative max-h-[600px] overflow-hidden ${isFirstImage ? "row-span-2" : ""}`}> + <Image + src={image.url as string} + alt="gweet image" + fill + className="object-cover rounded-lg" + /> + </Card> + ); + })} + </div> + )} + + {/* TODO */} + {/* {quoted_gweet && <QuotedGweet gweet={quoted_gweet} />} */} + </div> </div> </div> + + <div className="flex justify-end flex-grow w-full" > + <GweetActions gweet={gweet} /> + </div> </div> ); }; \ No newline at end of file diff --git a/components/gweets/components/gweets.tsx b/components/gweets/components/gweets.tsx index 679e40e24e44e7e1b2d08317d68d63bda9ff7ede..43bac1bb9f565076853268b98982a48ac6b4e4e4 100644 --- a/components/gweets/components/gweets.tsx +++ b/components/gweets/components/gweets.tsx @@ -28,7 +28,7 @@ export const Gweets = () => { } return ( - <Card className="w-full h-full mt-12 p-2 xl:p-4 "> + <Card className="w-full h-full mt-6 p-2 xl:p-4 "> <InfiniteGweets gweets={gweets} isSuccess={isSuccess} diff --git a/components/ui/button.tsx b/components/ui/button.tsx index c89803273006651945849f7f31898e88add3c5d7..6c84d7da14bc64ba4160bdd447e650ac57f486eb 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -17,13 +17,15 @@ const buttonVariants = cva( secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", + hover: "hover:bg-accent hover:text-accent-foreground hover:shadow-md hover:ring-2 hover:ring-ring hover:ring-offset-2", link: "underline-offset-4 hover:underline text-primary", }, size: { default: "h-10 py-2 px-4", sm: "h-9 px-3 rounded-md", lg: "h-11 px-8 rounded-full", - icon: "h-10 w-10", + icon: "h-10 w-10 rounded-full", + option: "h-8 w-8 rounded-full", }, }, defaultVariants: { diff --git a/lib/utils.ts b/lib/utils.ts index e467e9a31a883d5cc35e047e8c7a8c4e6b3237bc..2a53e0d288e6b8b53484f9e9be88e8663bab171d 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,6 @@ import { env } from "@/env.mjs" import { ClassValue, clsx } from "clsx" +import dayjs from "dayjs" import { twMerge } from "tailwind-merge" // tailwindcss classnames generator from shadcn @@ -28,15 +29,15 @@ export function formatDate(data: number) { } export function formatTimeElapsed(createdAt: Date) { - const now = new Date(); - const timeDiff = Math.abs(now.getTime() - new Date(createdAt).getTime()); // Difference in milliseconds + const now = dayjs(); + const timeDiff = Math.abs(now.diff(dayjs(createdAt))); // Difference in milliseconds const seconds = Math.floor(timeDiff / 1000); // Convert to seconds const minutes = Math.floor(seconds / 60); // Convert to minutes const hours = Math.floor(minutes / 60); // Convert to hours const days = Math.floor(hours / 24); // Convert to days if (days > 0) { - return new Date(createdAt).toLocaleDateString(); // Show the date if days have passed + return dayjs(createdAt).format('L'); // Show the date if days have passed } else if (hours > 0) { return hours + 'h'; // Show hours if hours have passed } else if (minutes > 0) {