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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 (
+
+ );
+}
+
+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}%` : ""}
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+}
+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