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..73f17df8 100644 --- a/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx +++ b/src/app/[lang]/crag/[cragSlug]/(crag)/gallery/page.tsx @@ -3,6 +3,9 @@ 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"; +import authStatus from "@/utils/auth/auth-status"; type TCragGalleryPageParams = { cragSlug: string; @@ -13,11 +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/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 new file mode 100644 index 00000000..caa1c713 --- /dev/null +++ b/src/components/image-upload/image-upload.tsx @@ -0,0 +1,442 @@ +"use client"; +import Button from "@/components/ui/button"; +import Dialog, { DialogSize, DialogTitleSize } from "@/components/ui/dialog"; +import { + ChangeEvent, + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import Checkbox from "../ui/checkbox"; +import TextField from "../ui/text-field"; +import { useRouter } from "next/navigation"; +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((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; +}; + +type TFormErrors = { + 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 = { + image: false, + title: false, + author: false, +}; + +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); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(); + const [percentage, setPercentage] = useState(0); + const [authorCheckbox, setAuthorCheckbox] = useState(user ? true : false); + 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 = 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 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; + } + setFormData({ ...formData, image: file }); + setPickedImageMetadata({ size: bytesToSize(file.size), name: file.name }); + + setFormError((prevState) => { + return { + ...prevState, + image: false, + }; + }); + const fileReader: FileReader = new FileReader(); + fileReader.onload = () => { + if (fileReader.result) { + setPickedImage(fileReader.result as string); + } + }; + fileReader.readAsDataURL(file); + } + }; + const handlePickClick = () => { + if (imageInput.current !== null) { + imageInput.current.click(); + } + }; + + const resetForm = useCallback(() => { + if (formRef.current !== null) { + formRef.current.reset(); + } + setPickedImage(undefined); + setAuthorCheckbox(true); + setFormData(initialFormState); + setFormError(INITIAL_ERROR_STATE); + setError(""); + }, [initialFormState]); + + const handleAuthorCheckboxClick = (value: boolean): void => { + setAuthorCheckbox(value); + if (user !== undefined) { + setFormData({ ...formData, author: user?.fullName }); + setFormError((prevState) => { + const newState = { + ...prevState, + author: false, + }; + return newState; + }); + } + }; + + const onFileUploadProgressChanged = (percentage: number) => { + setPercentage(percentage); + }; + + 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 = (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); + setFormData({ ...formData, image: null }); + if (imageInput.current !== null) { + imageInput.current.value = ""; + } + }; + + 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 && ( +
+
+ Fotografija +
+
+
+
+ + {pickedImageMetadata.name} +
+
+ + {pickedImageMetadata.size} +
+
+
+
+ +
+
+ {isSubmitting && ( +
+ +
+ )} +
+
+ )} + + {!pickedImage && ( + + )} + {formError?.image && ( +
+ Izberite fotografijo +
+ )} +
+ +
+
+ +
+
+ +
+ {error !== undefined && ( +
+ {error} +
+ )} +
+
+ ); +} + +export default ImageUpload; 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 ? (
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..3e7c7a70 --- /dev/null +++ b/src/components/ui/progress-bar.tsx @@ -0,0 +1,72 @@ +import { CSSProperties, forwardRef } from "react"; + +interface ProgressBarProps { + value: number; + size?: "small" | "default" | "large" | "extra-large"; + withLabelInside?: boolean; +} + +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; 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