From f88113e2cdab98b1bbb5d012a11ab498e43ead4c Mon Sep 17 00:00:00 2001 From: zoranmesec Date: Wed, 23 Oct 2024 08:00:49 +0200 Subject: [PATCH 1/5] - adding image upload component - image upload to crag gallery --- .env.local.example | 1 + .env.production | 1 + .../crag/[cragSlug]/(crag)/gallery/page.tsx | 14 +- src/components/image-upload/image-upload.tsx | 144 ++++++++++++++++++ .../server-actions/create-image-action.tsx | 27 ++++ src/components/ui/button.tsx | 3 + 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/components/image-upload/image-upload.tsx create mode 100644 src/components/image-upload/server-actions/create-image-action.tsx diff --git a/.env.local.example b/.env.local.example index 379b4395..e626e1d9 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/graphql +NEXT_PUBLIC_UPLOAD_URL=http://localhost:3000/upload IMAGES_PROTOCOL=https IMAGES_HOSTNAME=plezanje.net diff --git a/.env.production b/.env.production index d0e75708..eacb905e 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ NEXT_PUBLIC_API_URL=https://plezanje.info/graphql +NEXT_PUBLIC_UPLOAD_URL=https://plezanje.info/upload IMAGES_PROTOCOL=https IMAGES_HOSTNAME=plezanje.net diff --git a/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx b/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx index d3901a9d..c77378e2 100644 --- a/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx +++ b/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx @@ -3,6 +3,8 @@ import urqlServer from "@/graphql/urql-server"; import NextImage from "next/image"; import { gql } from "urql/core"; import ImageList from "./components/image-list"; +import ImageUpload from "@/components/image-upload/image-upload"; +import Button from "@/components/ui/button"; type TCragGalleryPageParams = { cragSlug: string; @@ -13,11 +15,21 @@ async function CragGalleryPage({ params }: { params: TCragGalleryPageParams }) { crag: params.cragSlug, }); + const images = data.cragBySlug.images as Image[]; const imagesBaseUrl = `${process.env.IMAGES_PROTOCOL}://${process.env.IMAGES_HOSTNAME}${process.env.IMAGES_PATHNAME}`; + + return ( -
+
+
+ Dodaj fotografijo} + entityType="crag" + entityId={data.cragBySlug.id} + /> +
); diff --git a/src/components/image-upload/image-upload.tsx b/src/components/image-upload/image-upload.tsx new file mode 100644 index 00000000..008959c6 --- /dev/null +++ b/src/components/image-upload/image-upload.tsx @@ -0,0 +1,144 @@ +'use client' +import Button from "@/components/ui/button"; +import Dialog, { DialogSize, DialogTitleSize } from "@/components/ui/dialog"; +import { ChangeEvent, FormEvent, ReactElement, useRef, useState } from "react"; +import Checkbox from "../ui/checkbox"; +import TextField from "../ui/text-field"; +import createImageAction from "./server-actions/create-image-action"; +import { useRouter } from "next/navigation"; +import Image from 'next/image'; +import { StaticImport } from "next/dist/shared/lib/get-img-props"; +type TImageUploadProps = { + openTrigger: ReactElement; + entityType: string; + entityId: string; +}; + +function ImageUpload({ openTrigger, entityType, entityId }: TImageUploadProps) { + const router = useRouter(); + + const [logDialogIsOpen, setLogDialogIsOpen] = useState(false); + const imageInput = useRef(null); + const titleRef = useRef(null); + const formRef = useRef(null); + const [pickedImage, setPickedImage] = useState(); + function handleImageChanged(event: ChangeEvent) { + if (event.target && event.target.files && event.target.files.length > 0) { + + const file = event.target.files[0]; + if (!file) { + setPickedImage(undefined); + return; + } + const fileReader: FileReader = new FileReader(); + fileReader.onload = () => { + if (fileReader.result) { + setPickedImage(fileReader.result as string); + } + }; + fileReader.readAsDataURL(file) + } + } + function handlePickClick() { + if (imageInput.current !== null) { + imageInput.current.click(); + } + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const fd = new FormData(event.currentTarget); + debugger + const file: File = fd.get('image') as File; + if (file.name.length == 0 && file.size == 0) { + return false; + } + + if (formRef.current !== null) { + + formRef.current.reset(); + } + + if (imageInput.current) { + imageInput.current.value = ""; + } + try { + setPickedImage(undefined); + // await createImageAction(fd); + // router.refresh(); + } catch (error) { + + } + return false; + }; + + const handleClose = () => { + if (formRef.current !== null) { + + formRef.current.requestSubmit(); + } + }; + return ( + +
+ + + {pickedImage && ( +
+ The image selected by the user. +
+ )} + + +
+ +
+
+ +
+
+ +
+
+
+ ); +} + +export default ImageUpload; diff --git a/src/components/image-upload/server-actions/create-image-action.tsx b/src/components/image-upload/server-actions/create-image-action.tsx new file mode 100644 index 00000000..224029b3 --- /dev/null +++ b/src/components/image-upload/server-actions/create-image-action.tsx @@ -0,0 +1,27 @@ +"use server"; +import getAuthToken from "@/utils/auth/auth-token"; + +async function createImageAction(formData: FormData) { + + const token = getAuthToken(); + + if (!token) return {}; + const response = await fetch( + `${process.env.NEXT_PUBLIC_UPLOAD_URL}/image`, + { + method: 'POST', + headers: { + authorization: token ? `Bearer ${token}` : "", + }, + body: formData, + } + ); + + if (!response.ok) { + throw new Error('Creating image data failed.'); + } + + return true; +} + +export default createImageAction; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index be1262b8..0e28273e 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -11,6 +11,7 @@ interface ButtonProps { variant?: "primary" | "secondary" | "tertiary" | "quaternary"; disabled?: boolean; loading?: boolean; + type?: "button" | "reset" | "submit"; onClick?: MouseEventHandler; } @@ -18,6 +19,7 @@ const Button = forwardRef(function Button( { children, variant = "primary", + type = "submit", disabled = false, loading = false, onClick, @@ -63,6 +65,7 @@ const Button = forwardRef(function Button( className={buttonStyles} disabled={disabled} onClick={onClick} + type={type} > {loading ? (
From fe3aa2d0a188ebcb6811a25be405178cc893b301 Mon Sep 17 00:00:00 2001 From: zoranmesec Date: Wed, 23 Oct 2024 15:49:37 +0200 Subject: [PATCH 2/5] - adding progress bar --- .../crag/[cragSlug]/(crag)/gallery/page.tsx | 12 +- src/components/image-upload/image-upload.tsx | 181 ++++++++++++++---- .../server-actions/create-image-action.tsx | 41 ++-- src/components/ui/dialog.tsx | 2 +- src/components/ui/progress-bar.tsx | 17 ++ src/utils/auth/auth-status.ts | 1 + src/utils/file-size.ts | 7 + 7 files changed, 202 insertions(+), 59 deletions(-) create mode 100644 src/components/ui/progress-bar.tsx create mode 100644 src/utils/file-size.ts diff --git a/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx b/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx index c77378e2..73f17df8 100644 --- a/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx +++ b/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx @@ -5,6 +5,7 @@ import { gql } from "urql/core"; import ImageList from "./components/image-list"; import ImageUpload from "@/components/image-upload/image-upload"; import Button from "@/components/ui/button"; +import authStatus from "@/utils/auth/auth-status"; type TCragGalleryPageParams = { cragSlug: string; @@ -15,21 +16,20 @@ async function CragGalleryPage({ params }: { params: TCragGalleryPageParams }) { crag: params.cragSlug, }); - + const currentUser = await authStatus(); const images = data.cragBySlug.images as Image[]; const imagesBaseUrl = `${process.env.IMAGES_PROTOCOL}://${process.env.IMAGES_HOSTNAME}${process.env.IMAGES_PATHNAME}`; - - return ( -
-
+
+ {currentUser.loggedIn &&
Dodaj fotografijo} entityType="crag" entityId={data.cragBySlug.id} + user={currentUser} /> -
+
}
); diff --git a/src/components/image-upload/image-upload.tsx b/src/components/image-upload/image-upload.tsx index 008959c6..72c778a1 100644 --- a/src/components/image-upload/image-upload.tsx +++ b/src/components/image-upload/image-upload.tsx @@ -1,4 +1,4 @@ -'use client' +"use client" import Button from "@/components/ui/button"; import Dialog, { DialogSize, DialogTitleSize } from "@/components/ui/dialog"; import { ChangeEvent, FormEvent, ReactElement, useRef, useState } from "react"; @@ -7,51 +7,138 @@ import TextField from "../ui/text-field"; import createImageAction from "./server-actions/create-image-action"; import { useRouter } from "next/navigation"; import Image from 'next/image'; -import { StaticImport } from "next/dist/shared/lib/get-img-props"; +import { AuthStatus } from "@/utils/auth/auth-status"; +import ProgressBar from "../ui/progress-bar"; +import { IconSize } from "../ui/icons/icon-size"; +import { bytesToSize } from "@/utils/file-size"; +import IconClose from "../ui/icons/close"; + type TImageUploadProps = { openTrigger: ReactElement; entityType: string; entityId: string; + user: AuthStatus }; -function ImageUpload({ openTrigger, entityType, entityId }: TImageUploadProps) { - const router = useRouter(); +type TFormErrors = { + file: boolean; + author: boolean; + title: boolean; +}; +type TImageMetdata = { + size: string; + name: string; +}; +const INITIAL_ERROR_STATE = { + file: false, + title: false, + author: false +}; +function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TImageUploadProps) { + const router = useRouter(); + const { user, token } = authStatus; const [logDialogIsOpen, setLogDialogIsOpen] = useState(false); + const [percentage, setPercentage] = useState(0); + const [authorCheckbox, setAuthorCheckbox] = useState(user ? true : false); + const [author, setAuthor] = useState(user !== undefined ? user.fullName : ""); + const [formError, setFormError] = useState(INITIAL_ERROR_STATE); const imageInput = useRef(null); const titleRef = useRef(null); const formRef = useRef(null); const [pickedImage, setPickedImage] = useState(); - function handleImageChanged(event: ChangeEvent) { - if (event.target && event.target.files && event.target.files.length > 0) { + const [pickedImageMetadata, setPickedImageMetadata] = useState({ + size: '', + name: '' + }); - const file = event.target.files[0]; + const handleImageChanged = (event: ChangeEvent) => { + if (event.target && event.target.files && event.target.files.length > 0) { + const file: File = event.target.files[0]; if (!file) { setPickedImage(undefined); return; } + console.log(file) + setPickedImageMetadata({ size: bytesToSize(file.size), name: file.name }); + + setFormError(prevState => { + return { + ...prevState, + file: false + } + }); const fileReader: FileReader = new FileReader(); fileReader.onload = () => { if (fileReader.result) { setPickedImage(fileReader.result as string); } }; - fileReader.readAsDataURL(file) + fileReader.readAsDataURL(file); } } - function handlePickClick() { + const handlePickClick = () => { if (imageInput.current !== null) { imageInput.current.click(); } } + + const handleClose = () => { + if (formRef.current !== null) { + formRef.current.requestSubmit(); + } + }; + + const handleCancel = () => { + if (formRef.current !== null) { + formRef.current.reset(); + } + setFormError(INITIAL_ERROR_STATE); + }; + + const handleAuthorCheckboxClick = (value: boolean): void => { + setAuthorCheckbox(value); + if (user !== undefined) { + setAuthor(user?.fullName); + setFormError((prevState) => { + const newState = { + ...prevState, + author: false + }; + return newState; + }); + } + } + + const handleAuthorChange = (value: string): void => { + setAuthor(value); + } + + const onFileUploadProgressChanged = (percentage: number) => { + setPercentage(Math.round(percentage * 100)); + } + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const fd = new FormData(event.currentTarget); - debugger const file: File = fd.get('image') as File; - if (file.name.length == 0 && file.size == 0) { - return false; + const title: string = fd.get('title') as string; + const formAuthor: string = fd.get('author') as string; + if ((file.name.length == 0 && file.size == 0) || title.length == 0 || (formAuthor !== null && formAuthor.length == 0)) { + setFormError((prevState) => { + const newState = { + ...prevState, + file: file.name.length == 0 && file.size == 0, + title: title.length == 0, + author: formAuthor !== null && formAuthor.length == 0 + }; + return newState; + }); + return; + } + if (formAuthor === null) { + fd.set('author', author); } if (formRef.current !== null) { @@ -62,22 +149,26 @@ function ImageUpload({ openTrigger, entityType, entityId }: TImageUploadProps) { if (imageInput.current) { imageInput.current.value = ""; } + + try { setPickedImage(undefined); - // await createImageAction(fd); - // router.refresh(); + await createImageAction(token ?? '', fd, onFileUploadProgressChanged); + setLogDialogIsOpen(false); + router.refresh(); } catch (error) { } return false; - }; - - const handleClose = () => { - if (formRef.current !== null) { + } - formRef.current.requestSubmit(); + const resetImagePreview = () => { + setPickedImage(undefined); + if (imageInput.current !== null) { + imageInput.current.value = ""; } - }; + } + return (
+ {pickedImage && ( -
- The image selected by the user. +
+
+ Fotografija +
+
+ + {pickedImageMetadata.name}
+
+ + {pickedImageMetadata.size}
+
+
+
+ +
)} - + {!pickedImage && } + {formError?.file &&
Izberite fotografijo
}
+ errorMessage={(formError?.title) ? "Vpišite naslov fotografije" : undefined} />
- +
+ isDisabled={authorCheckbox && (user !== undefined)} + placeholder="Ime in priimek avtorice oz. avtorja" + onChange={handleAuthorChange} + value={author} + errorMessage={(formError?.author) ? "Vpišite avtorico oz. avtorja fotografije" : undefined} />
diff --git a/src/components/image-upload/server-actions/create-image-action.tsx b/src/components/image-upload/server-actions/create-image-action.tsx index 224029b3..d0135cf2 100644 --- a/src/components/image-upload/server-actions/create-image-action.tsx +++ b/src/components/image-upload/server-actions/create-image-action.tsx @@ -1,27 +1,32 @@ -"use server"; -import getAuthToken from "@/utils/auth/auth-token"; +import axios from 'axios'; -async function createImageAction(formData: FormData) { +async function createImageAction(token: string, formData: FormData, progressCallback: (percentage: number) => void) { + if (!token) return {}; - const token = getAuthToken(); + await axios.request({ + method: "post", + url: `${process.env.NEXT_PUBLIC_UPLOAD_URL}/image`, + data: formData, + headers: { + authorization: token ? `Bearer ${token}` : "", + }, + onUploadProgress: (p) => { + console.log("progress", p); + if (p.progress !== undefined) { - if (!token) return {}; - const response = await fetch( - `${process.env.NEXT_PUBLIC_UPLOAD_URL}/image`, - { - method: 'POST', - headers: { - authorization: token ? `Bearer ${token}` : "", - }, - body: formData, + progressCallback(p.progress); + } } - ); + }).then(data => { + console.log(data); + if (data.status === 200) { - if (!response.ok) { - throw new Error('Creating image data failed.'); - } + } - return true; + return true; + }).catch(function (error) { + throw new Error('Creating image data failed.'); + }); } export default createImageAction; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 5e411805..1622894f 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -111,7 +111,7 @@ function Dialog({ > {title} - + {children} diff --git a/src/components/ui/progress-bar.tsx b/src/components/ui/progress-bar.tsx new file mode 100644 index 00000000..ddb7be46 --- /dev/null +++ b/src/components/ui/progress-bar.tsx @@ -0,0 +1,17 @@ +import { CSSProperties } from "react"; + +interface ProgressBarProps { + value: number; +} + +function ProgressBar(props: ProgressBarProps) { + const cssProperty: CSSProperties = { + width: `${props.value}%` + } + return ( +
+
+
+ ); +} +export default ProgressBar; diff --git a/src/utils/auth/auth-status.ts b/src/utils/auth/auth-status.ts index b964b9fd..f24e959d 100644 --- a/src/utils/auth/auth-status.ts +++ b/src/utils/auth/auth-status.ts @@ -38,6 +38,7 @@ gql` fullName email roles + gender } } `; diff --git a/src/utils/file-size.ts b/src/utils/file-size.ts new file mode 100644 index 00000000..b7bc4669 --- /dev/null +++ b/src/utils/file-size.ts @@ -0,0 +1,7 @@ +export function bytesToSize(bytes: number): string { + const sizes: string[] = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + if (bytes === 0) return 'n/a' + const i: number = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()) + if (i === 0) return `${bytes} ${sizes[i]}` + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}` +} \ No newline at end of file From 5af620274706e250f1dfbde69b9ca4cfaf9260cd Mon Sep 17 00:00:00 2001 From: zoranmesec Date: Thu, 24 Oct 2024 15:03:58 +0200 Subject: [PATCH 3/5] - improving progress bar - refactoring --- package.json | 1 + src/app/sandbox/progress-bar/page.tsx | 76 +++++++ src/components/image-upload/image-upload.tsx | 207 +++++++++++------- .../server-actions/create-image-action.tsx | 4 +- src/components/ui/progress-bar.tsx | 65 +++++- 5 files changed, 267 insertions(+), 86 deletions(-) create mode 100644 src/app/sandbox/progress-bar/page.tsx diff --git a/package.json b/package.json index cb879e4f..11b3c001 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@urql/next": "^1.0.0-beta.2", + "axios": "^1.7.7", "dayjs": "^1.11.11", "eslint": "^8.42.0", "eslint-config-next": "^14.0.3", diff --git a/src/app/sandbox/progress-bar/page.tsx b/src/app/sandbox/progress-bar/page.tsx new file mode 100644 index 00000000..26dec32b --- /dev/null +++ b/src/app/sandbox/progress-bar/page.tsx @@ -0,0 +1,76 @@ +"use client"; +import ProgressBar from "@/components/ui/progress-bar"; + +function ProgressBarPage() { + return ( +
+

Progress bar demo

+ +
+
At 0%
+
+ +
+
+ +
+
Size small
+
+ +
+
+ +
+
Default
+
+ +
+
+ +
+
Size large
+
+ +
+
+ +
+
Size extra large
+
+ +
+
+ +
+
With label inside
+
+ +
+
+ +
+
With label inside 100%
+
+ +
+
+ +
+
With label outside 100%
+
+ +
+
+ +
+
At 0% with label inside
+
+ +
+
+ +
+ ); +} + +export default ProgressBarPage; diff --git a/src/components/image-upload/image-upload.tsx b/src/components/image-upload/image-upload.tsx index 72c778a1..ad041489 100644 --- a/src/components/image-upload/image-upload.tsx +++ b/src/components/image-upload/image-upload.tsx @@ -1,7 +1,7 @@ "use client" import Button from "@/components/ui/button"; import Dialog, { DialogSize, DialogTitleSize } from "@/components/ui/dialog"; -import { ChangeEvent, FormEvent, ReactElement, useRef, useState } from "react"; +import { ChangeEvent, FormEvent, ReactElement, useEffect, useRef, useState } from "react"; import Checkbox from "../ui/checkbox"; import TextField from "../ui/text-field"; import createImageAction from "./server-actions/create-image-action"; @@ -9,7 +9,6 @@ import { useRouter } from "next/navigation"; import Image from 'next/image'; import { AuthStatus } from "@/utils/auth/auth-status"; import ProgressBar from "../ui/progress-bar"; -import { IconSize } from "../ui/icons/icon-size"; import { bytesToSize } from "@/utils/file-size"; import IconClose from "../ui/icons/close"; @@ -21,16 +20,25 @@ type TImageUploadProps = { }; type TFormErrors = { - file: boolean; + image: boolean; author: boolean; title: boolean; }; + +export type TFormData = { + image: File | null; + author: string; + title: string; + entityType: string; + entityId: string; +}; + type TImageMetdata = { size: string; name: string; }; const INITIAL_ERROR_STATE = { - file: false, + image: false, title: false, author: false }; @@ -39,13 +47,24 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI const router = useRouter(); const { user, token } = authStatus; const [logDialogIsOpen, setLogDialogIsOpen] = useState(false); - const [percentage, setPercentage] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + const [percentage, setPercentage] = useState(0); const [authorCheckbox, setAuthorCheckbox] = useState(user ? true : false); - const [author, setAuthor] = useState(user !== undefined ? user.fullName : ""); const [formError, setFormError] = useState(INITIAL_ERROR_STATE); const imageInput = useRef(null); const titleRef = useRef(null); const formRef = useRef(null); + + const initialFormState = { + image: null, + title: "", + author: user !== undefined ? user.fullName : "", + entityId: entityId, + entityType: entityType + }; + + const [formData, setFormData] = useState(initialFormState) const [pickedImage, setPickedImage] = useState(); const [pickedImageMetadata, setPickedImageMetadata] = useState({ size: '', @@ -59,13 +78,13 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI setPickedImage(undefined); return; } - console.log(file) + setFormData({ ...formData, image: file }); setPickedImageMetadata({ size: bytesToSize(file.size), name: file.name }); setFormError(prevState => { return { ...prevState, - file: false + image: false } }); const fileReader: FileReader = new FileReader(); @@ -83,24 +102,24 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI } } - const handleClose = () => { - if (formRef.current !== null) { - formRef.current.requestSubmit(); - } + setIsSubmitting(true); }; - const handleCancel = () => { + const resetForm = () => { if (formRef.current !== null) { formRef.current.reset(); } + setPickedImage(undefined); + setAuthorCheckbox(true); + setFormData(initialFormState); setFormError(INITIAL_ERROR_STATE); }; const handleAuthorCheckboxClick = (value: boolean): void => { setAuthorCheckbox(value); if (user !== undefined) { - setAuthor(user?.fullName); + setFormData({ ...formData, author: user?.fullName }); setFormError((prevState) => { const newState = { ...prevState, @@ -109,61 +128,91 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI return newState; }); } - } - const handleAuthorChange = (value: string): void => { - setAuthor(value); } const onFileUploadProgressChanged = (percentage: number) => { - setPercentage(Math.round(percentage * 100)); + setPercentage(percentage); } - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - const fd = new FormData(event.currentTarget); - const file: File = fd.get('image') as File; - const title: string = fd.get('title') as string; - const formAuthor: string = fd.get('author') as string; - if ((file.name.length == 0 && file.size == 0) || title.length == 0 || (formAuthor !== null && formAuthor.length == 0)) { - setFormError((prevState) => { - const newState = { - ...prevState, - file: file.name.length == 0 && file.size == 0, - title: title.length == 0, - author: formAuthor !== null && formAuthor.length == 0 - }; - return newState; - }); - return; - } - if (formAuthor === null) { - fd.set('author', author); - } + useEffect(() => setError(""), [logDialogIsOpen]); - if (formRef.current !== null) { + useEffect(() => { + if (isSubmitting) { + setError(""); + const createImage = async (): Promise => { + try { + const fd = new FormData(); + fd.append('author', formData.author); + fd.append('title', formData.title); + fd.append('image', formData.image as Blob); + fd.append('entityId', formData.entityId); + fd.append('entityType', formData.entityType); + await createImageAction(token ?? '', fd, onFileUploadProgressChanged); + setLogDialogIsOpen(false); + resetForm(); - formRef.current.reset(); - } + router.refresh(); + } catch (error) { + setError("Prišlo je do napake pri shranjevanju fotografije."); + resetForm(); + } + setIsSubmitting(false); + } - if (imageInput.current) { - imageInput.current.value = ""; + const file: File | null = formData.image; + const title: string = formData.title; + const formAuthor: string = formData.author; + + if (file === null || title.length == 0 || (formAuthor !== null && formAuthor.length == 0)) { + setFormError((prevState) => { + const newState = { + ...prevState, + image: file === null, + title: title.length == 0, + author: formAuthor !== null && formAuthor.length == 0 + }; + return newState; + }); + setIsSubmitting(false); + } else { + if (formRef.current !== null) { + formRef.current.reset(); + } + if (imageInput.current) { + imageInput.current.value = ""; + } + + createImage(); + + + } } + }, [isSubmitting]) + + + const handleSubmit = async (event: FormEvent) => { + setIsSubmitting(true); + event.preventDefault(); - try { - setPickedImage(undefined); - await createImageAction(token ?? '', fd, onFileUploadProgressChanged); - setLogDialogIsOpen(false); - router.refresh(); - } catch (error) { + } + const handleTitleChange = async (value: string) => { + if (titleRef.current !== null) { + setFormData({ ...formData, title: value }); + } + } + + const handleAuthorChange = async (value: string) => { + if (titleRef.current !== null) { + setFormData({ ...formData, author: value }) } - return false; } const resetImagePreview = () => { setPickedImage(undefined); + setFormData({ ...formData, image: null }); if (imageInput.current !== null) { imageInput.current.value = ""; } @@ -178,25 +227,22 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI confirm={{ label: "Objavi", callback: handleClose, - disabled: false, + disabled: isSubmitting, loading: false, dontCloseOnConfirm: true, }} cancel={{ label: "Prekliči", - callback: handleCancel, - disabled: false, + callback: resetForm, + disabled: isSubmitting, }} isOpen={logDialogIsOpen} - closeCallback={handleCancel} + closeCallback={resetForm} setIsOpen={setLogDialogIsOpen} >
- - - {pickedImage && (
-
+
Fotografija
-
- - {pickedImageMetadata.name}
-
- - {pickedImageMetadata.size}
-
-
-
- +
+
+
+ + {pickedImageMetadata.name}
+
+ + {pickedImageMetadata.size}
+
+
+
+ +
+ +
+ {isSubmitting &&
+ +
}
)} @@ -229,26 +283,29 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI onChange={handleImageChanged} ref={imageInput} /> {!pickedImage && } - {formError?.file &&
Izberite fotografijo
} + {formError?.image &&
Izberite fotografijo
}
- +
+ {error !== undefined &&
{error}
} ); diff --git a/src/components/image-upload/server-actions/create-image-action.tsx b/src/components/image-upload/server-actions/create-image-action.tsx index d0135cf2..c694cc75 100644 --- a/src/components/image-upload/server-actions/create-image-action.tsx +++ b/src/components/image-upload/server-actions/create-image-action.tsx @@ -11,20 +11,18 @@ async function createImageAction(token: string, formData: FormData, progressCall authorization: token ? `Bearer ${token}` : "", }, onUploadProgress: (p) => { - console.log("progress", p); if (p.progress !== undefined) { progressCallback(p.progress); } } }).then(data => { - console.log(data); if (data.status === 200) { } return true; - }).catch(function (error) { + }).catch(function () { throw new Error('Creating image data failed.'); }); } diff --git a/src/components/ui/progress-bar.tsx b/src/components/ui/progress-bar.tsx index ddb7be46..2df1807b 100644 --- a/src/components/ui/progress-bar.tsx +++ b/src/components/ui/progress-bar.tsx @@ -1,17 +1,66 @@ -import { CSSProperties } from "react"; +import { CSSProperties, forwardRef } from "react"; interface ProgressBarProps { value: number; + size?: "small" | "default" | "large" | "extra-large"; + withLabelInside?: boolean; } -function ProgressBar(props: ProgressBarProps) { - const cssProperty: CSSProperties = { - width: `${props.value}%` +function ProgressBar({ + value, + size = 'default', + withLabelInside = false +}: ProgressBarProps) { + + let barStyles = 'bg-blue-500 rounded-full text-blue-100 text-center leading-none'; + let frameStyles = 'bg-neutral-100 rounded-full w-full mt-2'; + const percentage = Math.round(value * 100); + switch (size) { + case 'small': + barStyles += ' h-1.5'; + frameStyles += ' h-1.5'; + break; + case 'large': + barStyles += ' h-4'; + frameStyles += ' h-4'; + break; + case 'extra-large': + barStyles += ' h-6'; + frameStyles += ' h-6'; + break; + default: + barStyles += ' h-2.5'; + frameStyles += ' h-2.5'; + break; + } + if (withLabelInside) { + if (size !== 'extra-large') { //reduce font size for inside label + barStyles += ' text-sm p-0.5'; + } else { + barStyles += ' text-m p-1'; + } } + + + const cssProperty: CSSProperties = { + width: `${value * 100}%` + }; + return ( -
-
-
+ <> + {withLabelInside ? (
+
+
{withLabelInside ? `${percentage}%` : ''}
+
+
) : (
+
+
+
+
{`${percentage}%`}
+
) + + } + ); -} +}; export default ProgressBar; From 14cc44de0c2c6ab86977d0bacd43ca4788b801d0 Mon Sep 17 00:00:00 2001 From: zoranmesec Date: Mon, 18 Nov 2024 23:30:01 +0100 Subject: [PATCH 4/5] - bugfixes, code refactoring, linting --- package.json | 1 - src/components/image-upload/image-upload.tsx | 336 +++++++++++++----- .../server-actions/create-image-action.tsx | 30 -- src/components/ui/progress-bar.tsx | 72 ++-- 4 files changed, 278 insertions(+), 161 deletions(-) delete mode 100644 src/components/image-upload/server-actions/create-image-action.tsx diff --git a/package.json b/package.json index 11b3c001..cb879e4f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@urql/next": "^1.0.0-beta.2", - "axios": "^1.7.7", "dayjs": "^1.11.11", "eslint": "^8.42.0", "eslint-config-next": "^14.0.3", diff --git a/src/components/image-upload/image-upload.tsx b/src/components/image-upload/image-upload.tsx index ad041489..2c8f15db 100644 --- a/src/components/image-upload/image-upload.tsx +++ b/src/components/image-upload/image-upload.tsx @@ -1,22 +1,89 @@ -"use client" +"use client"; import Button from "@/components/ui/button"; import Dialog, { DialogSize, DialogTitleSize } from "@/components/ui/dialog"; -import { ChangeEvent, FormEvent, ReactElement, useEffect, useRef, useState } from "react"; +import { + ChangeEvent, + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import Checkbox from "../ui/checkbox"; import TextField from "../ui/text-field"; -import createImageAction from "./server-actions/create-image-action"; import { useRouter } from "next/navigation"; -import Image from 'next/image'; +import Image from "next/image"; import { AuthStatus } from "@/utils/auth/auth-status"; import ProgressBar from "../ui/progress-bar"; import { bytesToSize } from "@/utils/file-size"; import IconClose from "../ui/icons/close"; +type TImageUploadResponse = { + aspectRatio: number; + author: string; + created: string; + description: string | null; + extension: string; + id: string; + legacy: string | null; + maxIntrinsicWidth: number; + path: string; + title: string; + updated: string; + userId: string; + __crag__: unknown; + __has_crag__: boolean; + __has_user__: boolean; + __user__: unknown; +}; + +async function createImageAction( + token: string, + formData: FormData, + progressCallback: (percentage: number) => void +) { + return new Promise(function (resolve, reject) { + if (!token) reject({ status: 401, statusText: "Missing token" }); + + try { + var xhr = new XMLHttpRequest(); + xhr.open("POST", `${process.env.NEXT_PUBLIC_UPLOAD_URL}/image`, true); + xhr.setRequestHeader("authorization", token ? `Bearer ${token}` : ""); + xhr.upload.onerror = () => { + reject({ + status: xhr.status, + statusText: xhr.statusText, + }); + }; + + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 201) { + resolve(xhr.response as TImageUploadResponse); + } else if (xhr.readyState === XMLHttpRequest.DONE) { + reject({ + status: xhr.status, + statusText: xhr.statusText, + }); + } + }; + + xhr.upload.onprogress = (e) => { + const percentComplete = e.loaded / e.total; + progressCallback(percentComplete); + }; + xhr.send(formData); + } catch (error) { + reject({ status: 500, statusText: error }); + } + }); +} + type TImageUploadProps = { openTrigger: ReactElement; entityType: string; entityId: string; - user: AuthStatus + user: AuthStatus; }; type TFormErrors = { @@ -40,10 +107,17 @@ type TImageMetdata = { const INITIAL_ERROR_STATE = { image: false, title: false, - author: false + author: false, }; -function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TImageUploadProps) { +const ERROR_MESSAGE = "Prišlo je do napake pri shranjevanju fotografije."; + +function ImageUpload({ + openTrigger, + entityType, + entityId, + user: authStatus, +}: TImageUploadProps) { const router = useRouter(); const { user, token } = authStatus; const [logDialogIsOpen, setLogDialogIsOpen] = useState(false); @@ -54,22 +128,27 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI const [formError, setFormError] = useState(INITIAL_ERROR_STATE); const imageInput = useRef(null); const titleRef = useRef(null); + const authorRef = useRef(null); const formRef = useRef(null); - const initialFormState = { - image: null, - title: "", - author: user !== undefined ? user.fullName : "", - entityId: entityId, - entityType: entityType - }; - - const [formData, setFormData] = useState(initialFormState) + const initialFormState = useMemo(() => { + return { + image: null, + title: "", + author: user !== undefined ? user.fullName : "", + entityId: entityId, + entityType: entityType, + }; + }, [entityId, entityType, user]); + + const [formData, setFormData] = useState(initialFormState); const [pickedImage, setPickedImage] = useState(); - const [pickedImageMetadata, setPickedImageMetadata] = useState({ - size: '', - name: '' - }); + const [pickedImageMetadata, setPickedImageMetadata] = useState( + { + size: "", + name: "", + } + ); const handleImageChanged = (event: ChangeEvent) => { if (event.target && event.target.files && event.target.files.length > 0) { @@ -81,11 +160,11 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI setFormData({ ...formData, image: file }); setPickedImageMetadata({ size: bytesToSize(file.size), name: file.name }); - setFormError(prevState => { + setFormError((prevState) => { return { ...prevState, - image: false - } + image: false, + }; }); const fileReader: FileReader = new FileReader(); fileReader.onload = () => { @@ -95,18 +174,14 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI }; fileReader.readAsDataURL(file); } - } + }; const handlePickClick = () => { if (imageInput.current !== null) { imageInput.current.click(); } - } - - const handleClose = () => { - setIsSubmitting(true); }; - const resetForm = () => { + const resetForm = useCallback(() => { if (formRef.current !== null) { formRef.current.reset(); } @@ -114,7 +189,8 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI setAuthorCheckbox(true); setFormData(initialFormState); setFormError(INITIAL_ERROR_STATE); - }; + setError(""); + }, [initialFormState]); const handleAuthorCheckboxClick = (value: boolean): void => { setAuthorCheckbox(value); @@ -123,19 +199,16 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI setFormError((prevState) => { const newState = { ...prevState, - author: false + author: false, }; return newState; }); } - - } + }; const onFileUploadProgressChanged = (percentage: number) => { setPercentage(percentage); - } - - useEffect(() => setError(""), [logDialogIsOpen]); + }; useEffect(() => { if (isSubmitting) { @@ -143,72 +216,89 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI const createImage = async (): Promise => { try { const fd = new FormData(); - fd.append('author', formData.author); - fd.append('title', formData.title); - fd.append('image', formData.image as Blob); - fd.append('entityId', formData.entityId); - fd.append('entityType', formData.entityType); - await createImageAction(token ?? '', fd, onFileUploadProgressChanged); - setLogDialogIsOpen(false); - resetForm(); - - router.refresh(); + fd.append("author", formData.author); + fd.append("title", formData.title); + fd.append("image", formData.image as Blob); + fd.append("entityId", formData.entityId); + fd.append("entityType", formData.entityType); + createImageAction(token ?? "", fd, onFileUploadProgressChanged) + .then(() => { + setLogDialogIsOpen(false); + resetForm(); + router.refresh(); + }) + .catch(() => { + setError(ERROR_MESSAGE); + }) + .finally(() => { + setIsSubmitting(false); + }); } catch (error) { - setError("Prišlo je do napake pri shranjevanju fotografije."); - resetForm(); + setError(ERROR_MESSAGE); + setIsSubmitting(false); } - setIsSubmitting(false); - } + }; const file: File | null = formData.image; const title: string = formData.title; const formAuthor: string = formData.author; - if (file === null || title.length == 0 || (formAuthor !== null && formAuthor.length == 0)) { + if ( + file === null || + title.length == 0 || + (formAuthor !== null && formAuthor.length == 0) + ) { setFormError((prevState) => { const newState = { ...prevState, image: file === null, title: title.length == 0, - author: formAuthor !== null && formAuthor.length == 0 + author: formAuthor !== null && formAuthor.length == 0, }; return newState; }); setIsSubmitting(false); } else { - if (formRef.current !== null) { - formRef.current.reset(); - } - if (imageInput.current) { - imageInput.current.value = ""; - } - createImage(); - - } } - - }, [isSubmitting]) - - - const handleSubmit = async (event: FormEvent) => { - setIsSubmitting(true); - event.preventDefault(); - - } + }, [ + formData.author, + formData.entityId, + formData.entityType, + formData.image, + formData.title, + isSubmitting, + resetForm, + router, + token, + ]); const handleTitleChange = async (value: string) => { if (titleRef.current !== null) { setFormData({ ...formData, title: value }); + setFormError((prevState) => { + const newState = { + ...prevState, + title: value.length === 0, + }; + return newState; + }); } - } + }; - const handleAuthorChange = async (value: string) => { - if (titleRef.current !== null) { - setFormData({ ...formData, author: value }) + const handleAuthorChange = (value: string) => { + if (authorRef.current !== null) { + setFormData({ ...formData, author: value }); + setFormError((prevState) => { + const newState = { + ...prevState, + author: value.length === 0, + }; + return newState; + }); } - } + }; const resetImagePreview = () => { setPickedImage(undefined); @@ -216,6 +306,10 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI if (imageInput.current !== null) { imageInput.current.value = ""; } + }; + + function handleClose(): void { + setIsSubmitting(true); } return ( @@ -238,8 +332,9 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI }} isOpen={logDialogIsOpen} closeCallback={resetForm} - setIsOpen={setLogDialogIsOpen} > -
+ setIsOpen={setLogDialogIsOpen} + > + {pickedImage && (
@@ -255,57 +350,104 @@ function ImageUpload({ openTrigger, entityType, entityId, user: authStatus }: TI
- {pickedImageMetadata.name}
+ {pickedImageMetadata.name} +
- {pickedImageMetadata.size}
+ {pickedImageMetadata.size} +
-
-
- {isSubmitting &&
- -
} + {isSubmitting && ( +
+ +
+ )}
)} - {!pickedImage && } - {formError?.image &&
Izberite fotografijo
} + ref={imageInput} + /> + {!pickedImage && ( + + )} + {formError?.image && ( +
+ Izberite fotografijo +
+ )}
- + errorMessage={ + formError?.title ? "Vpišite naslov fotografije" : undefined + } + />
- +
+ errorMessage={ + formError?.author + ? "Vpišite avtorico oz. avtorja fotografije" + : undefined + } + />
- {error !== undefined &&
{error}
} + {error !== undefined && ( +
+ {error} +
+ )}
); diff --git a/src/components/image-upload/server-actions/create-image-action.tsx b/src/components/image-upload/server-actions/create-image-action.tsx deleted file mode 100644 index c694cc75..00000000 --- a/src/components/image-upload/server-actions/create-image-action.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import axios from 'axios'; - -async function createImageAction(token: string, formData: FormData, progressCallback: (percentage: number) => void) { - if (!token) return {}; - - await axios.request({ - method: "post", - url: `${process.env.NEXT_PUBLIC_UPLOAD_URL}/image`, - data: formData, - headers: { - authorization: token ? `Bearer ${token}` : "", - }, - onUploadProgress: (p) => { - if (p.progress !== undefined) { - - progressCallback(p.progress); - } - } - }).then(data => { - if (data.status === 200) { - - } - - return true; - }).catch(function () { - throw new Error('Creating image data failed.'); - }); -} - -export default createImageAction; diff --git a/src/components/ui/progress-bar.tsx b/src/components/ui/progress-bar.tsx index 2df1807b..3e7c7a70 100644 --- a/src/components/ui/progress-bar.tsx +++ b/src/components/ui/progress-bar.tsx @@ -8,59 +8,65 @@ interface ProgressBarProps { function ProgressBar({ value, - size = 'default', - withLabelInside = false + size = "default", + withLabelInside = false, }: ProgressBarProps) { - - let barStyles = 'bg-blue-500 rounded-full text-blue-100 text-center leading-none'; - let frameStyles = 'bg-neutral-100 rounded-full w-full mt-2'; + let barStyles = + "bg-blue-500 rounded-full text-blue-100 text-center leading-none"; + let frameStyles = "bg-neutral-100 rounded-full w-full mt-2"; const percentage = Math.round(value * 100); switch (size) { - case 'small': - barStyles += ' h-1.5'; - frameStyles += ' h-1.5'; + case "small": + barStyles += " h-1.5"; + frameStyles += " h-1.5"; break; - case 'large': - barStyles += ' h-4'; - frameStyles += ' h-4'; + case "large": + barStyles += " h-4"; + frameStyles += " h-4"; break; - case 'extra-large': - barStyles += ' h-6'; - frameStyles += ' h-6'; + case "extra-large": + barStyles += " h-6"; + frameStyles += " h-6"; break; default: - barStyles += ' h-2.5'; - frameStyles += ' h-2.5'; + barStyles += " h-2.5"; + frameStyles += " h-2.5"; break; } if (withLabelInside) { - if (size !== 'extra-large') { //reduce font size for inside label - barStyles += ' text-sm p-0.5'; + if (size !== "extra-large") { + //reduce font size for inside label + barStyles += " text-sm p-0.5"; } else { - barStyles += ' text-m p-1'; + barStyles += " text-m p-1"; } } - const cssProperty: CSSProperties = { - width: `${value * 100}%` + width: `${value * 100}%`, }; return ( <> - {withLabelInside ? (
-
-
{withLabelInside ? `${percentage}%` : ''}
+ {withLabelInside ? ( +
+
+
+ {withLabelInside ? `${percentage}%` : ""} +
+
-
) : (
-
-
-
-
{`${percentage}%`}
-
) - - } + ) : ( +
+
+
+
+
{`${percentage}%`}
+
+ )} ); -}; +} export default ProgressBar; From 570436c8b575abb3d93474cb28e57b8b9a48c64b Mon Sep 17 00:00:00 2001 From: zoranmesec Date: Tue, 19 Nov 2024 19:16:49 +0100 Subject: [PATCH 5/5] - use of form action instead of useEffect --- src/components/image-upload/image-upload.tsx | 122 ++++++++----------- 1 file changed, 54 insertions(+), 68 deletions(-) diff --git a/src/components/image-upload/image-upload.tsx b/src/components/image-upload/image-upload.tsx index 2c8f15db..caa1c713 100644 --- a/src/components/image-upload/image-upload.tsx +++ b/src/components/image-upload/image-upload.tsx @@ -43,7 +43,7 @@ async function createImageAction( formData: FormData, progressCallback: (percentage: number) => void ) { - return new Promise(function (resolve, reject) { + return new Promise((resolve, reject) => { if (!token) reject({ status: 401, statusText: "Missing token" }); try { @@ -210,70 +210,6 @@ function ImageUpload({ setPercentage(percentage); }; - useEffect(() => { - if (isSubmitting) { - setError(""); - const createImage = async (): Promise => { - try { - const fd = new FormData(); - fd.append("author", formData.author); - fd.append("title", formData.title); - fd.append("image", formData.image as Blob); - fd.append("entityId", formData.entityId); - fd.append("entityType", formData.entityType); - createImageAction(token ?? "", fd, onFileUploadProgressChanged) - .then(() => { - setLogDialogIsOpen(false); - resetForm(); - router.refresh(); - }) - .catch(() => { - setError(ERROR_MESSAGE); - }) - .finally(() => { - setIsSubmitting(false); - }); - } catch (error) { - setError(ERROR_MESSAGE); - setIsSubmitting(false); - } - }; - - const file: File | null = formData.image; - const title: string = formData.title; - const formAuthor: string = formData.author; - - if ( - file === null || - title.length == 0 || - (formAuthor !== null && formAuthor.length == 0) - ) { - setFormError((prevState) => { - const newState = { - ...prevState, - image: file === null, - title: title.length == 0, - author: formAuthor !== null && formAuthor.length == 0, - }; - return newState; - }); - setIsSubmitting(false); - } else { - createImage(); - } - } - }, [ - formData.author, - formData.entityId, - formData.entityType, - formData.image, - formData.title, - isSubmitting, - resetForm, - router, - token, - ]); - const handleTitleChange = async (value: string) => { if (titleRef.current !== null) { setFormData({ ...formData, title: value }); @@ -308,9 +244,59 @@ function ImageUpload({ } }; - function handleClose(): void { + const handleClose = () => { + formRef.current?.requestSubmit(); + }; + + const handleFormAction = async () => { setIsSubmitting(true); - } + setError(""); + + const file: File | null = formData.image; + const title: string = formData.title; + const formAuthor: string = formData.author; + + if ( + file === null || + title.length == 0 || + (formAuthor !== null && formAuthor.length == 0) + ) { + setFormError((prevState) => { + const newState = { + ...prevState, + image: file === null, + title: title.length == 0, + author: formAuthor !== null && formAuthor.length == 0, + }; + return newState; + }); + setIsSubmitting(false); + } else { + try { + const fd = new FormData(); + fd.append("author", formData.author); + fd.append("title", formData.title); + fd.append("image", formData.image as Blob); + fd.append("entityId", formData.entityId); + fd.append("entityType", formData.entityType); + createImageAction(token ?? "", fd, onFileUploadProgressChanged) + .then(() => { + setLogDialogIsOpen(false); + resetForm(); + router.refresh(); + }) + .catch(() => { + setError(ERROR_MESSAGE); + }) + .finally(() => { + setIsSubmitting(false); + }); + } catch (error) { + setError(ERROR_MESSAGE); + setIsSubmitting(false); + } + } + }; return ( -
+ {pickedImage && (