diff --git a/src/App.jsx b/src/App.jsx index 8fad7b4..0848865 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,12 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import Home from './pages/Home'; -import Login from './pages/Login'; -import { useAuthStore } from './store/authStore'; +import { BrowserRouter as Router, Navigate, Route, Routes } from "react-router-dom"; +import AppLayout from "./components/AppLayout"; +import Login from "./pages/Login"; +import CreateNotice from "./pages/CreateNotice"; +import EditNotice from "./pages/EditNotice"; +import MyNotices from "./pages/MyNotices"; +import NoticeDetails from "./pages/NoticeDetails"; +import NoticesPage from "./pages/NoticesPage"; +import { useAuthStore } from "./store/authStore"; const ProtectedRoute = ({ children }) => { const token = useAuthStore((state) => state.token); @@ -18,14 +22,19 @@ function App() { } /> - + } - /> - {/* Fallback */} + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/api/categories.js b/src/api/categories.js new file mode 100644 index 0000000..2e58b38 --- /dev/null +++ b/src/api/categories.js @@ -0,0 +1,12 @@ +import axios from "axios"; +import { useAuthStore } from "../store/authStore"; + +const API_URL = "/api/v1"; + +export async function listCategories() { + const { token } = useAuthStore.getState(); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + + const response = await axios.get(`${API_URL}/vars/categories`, { headers }); + return Array.isArray(response.data) ? response.data : []; +} diff --git a/src/api/notices.js b/src/api/notices.js index 78bd291..648b6ca 100644 --- a/src/api/notices.js +++ b/src/api/notices.js @@ -2,10 +2,26 @@ import axios from "axios"; import { useAuthStore } from "../store/authStore"; const API_URL = "/api/v1"; +const FALLBACK_IMAGE_URL = "https://http.cat/404.jpg"; + +const getAuthHeaders = () => { + const { token } = useAuthStore.getState(); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +const buildImageUrl = (imageName) => `${API_URL}/images/get/${imageName}`; + +const fetchImageAsBlobUrl = async (imageUrl) => { + const headers = getAuthHeaders(); + const response = await axios.get(imageUrl, { + headers, + responseType: "blob", + }); + return URL.createObjectURL(response.data); +}; export async function listNotices() { - const { token } = useAuthStore.getState(); - const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const headers = getAuthHeaders(); const response = await fetch(`${API_URL}/notices/get/all`, { headers: headers, @@ -19,7 +35,10 @@ export async function listNotices() { } export async function getNoticeById(noticeId) { - const response = await fetch(`${API_URL}/notices/get/${noticeId}`); + const headers = getAuthHeaders(); + const response = await fetch(`${API_URL}/notices/get/${noticeId}`, { + headers, + }); const data = await response.json(); if (!response.ok) { throw new Error("Error fetching notice"); @@ -28,16 +47,28 @@ export async function getNoticeById(noticeId) { } export async function createNotice(notice) { - const { token } = useAuthStore.getState(); - const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const headers = getAuthHeaders(); + + const payload = { + title: notice.title, + description: notice.description, + price: notice.price, + category: notice.category, + status: notice.status, + attributes: notice.attributes, + }; + try { - const response = await axios.post(`${API_URL}/notices/add`, notice, { + const response = await axios.post(`${API_URL}/notices/add`, payload, { headers: headers, }); if (response.data.noticeId !== null && notice.image) { for (let i = 0; i < notice.image.length; i++) { - await uploadImage(response.data.noticeId, notice.image[i], i === 0); + const image = notice.image[i]; + if (image instanceof File) { + await uploadImage(response.data.noticeId, image, i === 0); + } } } @@ -49,41 +80,48 @@ export async function createNotice(notice) { } export async function getImageByNoticeId(noticeId) { + const headers = getAuthHeaders(); + try { - const listResponse = await axios.get(`${API_URL}/images/list/${noticeId}`); + const listResponse = await axios.get(`${API_URL}/images/list/${noticeId}`, { + headers, + }); const imageName = listResponse.data[0]; - return `${API_URL}/images/get/${imageName}`; - } catch (err) { - return "https://http.cat/404.jpg"; + if (!imageName) { + return FALLBACK_IMAGE_URL; + } + return await fetchImageAsBlobUrl(buildImageUrl(imageName)); + } catch { + return FALLBACK_IMAGE_URL; } } export async function getAllImagesByNoticeId(noticeId) { - const { token } = useAuthStore.getState(); - const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const headers = getAuthHeaders(); try { const listResponse = await axios.get(`${API_URL}/images/list/${noticeId}`, { headers: headers, }); if (listResponse.data && listResponse.data.length > 0) { - return listResponse.data.map((imageName) => ({ - uri: `${API_URL}/images/get/${imageName}`, - headers: headers, - })); + return await Promise.all( + listResponse.data.map(async (imageName) => { + try { + return await fetchImageAsBlobUrl(buildImageUrl(imageName)); + } catch { + return FALLBACK_IMAGE_URL; + } + }) + ); } - return [{ uri: "https://http.cat/404.jpg" }]; - } catch (err) { - return [{ uri: "https://http.cat/404.jpg" }]; + return [FALLBACK_IMAGE_URL]; + } catch { + return [FALLBACK_IMAGE_URL]; } } export const uploadImage = async (noticeId, file, isFirst) => { - const { token } = useAuthStore.getState(); - const headers = { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - "Content-Type": "multipart/form-data", - }; + const headers = getAuthHeaders(); const formData = new FormData(); formData.append("file", file); @@ -101,64 +139,62 @@ export const uploadImage = async (noticeId, file, isFirst) => { }; export const deleteNotice = async (noticeId) => { - const { token } = useAuthStore.getState(); - const headers = token ? { Authorization: `Bearer ${token}` } : {}; - - try { - const response = await axios.delete( - `${API_URL}/notices/delete/${noticeId}`, - { headers: headers } - ); - return response.data; - } catch (error) { - throw error; - } + const headers = getAuthHeaders(); + const response = await axios.delete( + `${API_URL}/notices/delete/${noticeId}`, + { headers: headers } + ); + return response.data; }; export const editNotice = async (noticeId, notice) => { - const { token } = useAuthStore.getState(); - const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const headers = getAuthHeaders(); + const response = await axios.put( + `${API_URL}/notices/edit/${noticeId}`, + { + title: notice.title, + description: notice.description, + price: notice.price, + category: notice.category, + status: notice.status, + attributes: notice.attributes, + }, + { headers: headers } + ); - try { - const response = await axios.put( - `${API_URL}/notices/edit/${noticeId}`, - { - title: notice.title, - description: notice.description, - price: notice.price, - category: notice.category, - status: notice.status, - attributes: notice.attributes, - }, - { headers: headers } - ); - - if (response.data && notice.image && notice.image.length > 0) { - for (let i = 0; i < notice.image.length; i++) { - const image = notice.image[i]; - if (image instanceof File) { - await uploadImage(noticeId, image, i === 0); - } + if (response.data && notice.image && notice.image.length > 0) { + for (let i = 0; i < notice.image.length; i++) { + const image = notice.image[i]; + if (image instanceof File) { + await uploadImage(noticeId, image, i === 0); } } - - return response.data; - } catch (error) { - throw error; } + + return response.data; }; export const deleteImage = async (filename) => { - const { token } = useAuthStore.getState(); - const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const headers = getAuthHeaders(); + const response = await axios.delete( + `${API_URL}/images/delete/${filename}`, + { headers: headers } + ); + return response.data; +}; + +export async function listImageNamesByNoticeId(noticeId) { + const headers = getAuthHeaders(); try { - const response = await axios.delete( - `${API_URL}/images/delete/${filename}`, - { headers: headers } - ); - return response.data; + const response = await axios.get(`${API_URL}/images/list/${noticeId}`, { + headers, + }); + return Array.isArray(response.data) ? response.data : []; } catch (error) { + if (error.response?.status === 404) { + return []; + } throw error; } -}; +} diff --git a/src/components/AppLayout.jsx b/src/components/AppLayout.jsx new file mode 100644 index 0000000..986bb1e --- /dev/null +++ b/src/components/AppLayout.jsx @@ -0,0 +1,50 @@ +import { LogOut, PlusCircle } from "lucide-react"; +import { NavLink, Outlet } from "react-router-dom"; +import { useAuthStore } from "../store/authStore"; + +const navClass = ({ isActive }) => + `px-3 py-2 text-sm font-medium rounded-md transition-colors ${ + isActive ? "bg-primary/10 text-primary" : "text-gray-600 hover:text-gray-900" + }`; + +export default function AppLayout() { + const { signOut } = useAuthStore(); + + return ( +
+
+
+ + ArtisanConnect + + +
+
+
+ +
+
+ ); +} diff --git a/src/components/NoticeCard.jsx b/src/components/NoticeCard.jsx new file mode 100644 index 0000000..c496395 --- /dev/null +++ b/src/components/NoticeCard.jsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { listCategories } from "../api/categories"; +import { getImageByNoticeId } from "../api/notices"; + +let categoryLabelCache = null; +let categoryLabelPromise = null; + +const getCategoryLabelMap = async () => { + if (categoryLabelCache) { + return categoryLabelCache; + } + if (categoryLabelPromise) { + return categoryLabelPromise; + } + + categoryLabelPromise = listCategories() + .then((items) => { + const map = new Map( + (items || []).map((item) => [String(item.value), item.label]) + ); + categoryLabelCache = map; + return map; + }) + .catch(() => new Map()) + .finally(() => { + categoryLabelPromise = null; + }); + + return categoryLabelPromise; +}; + +export default function NoticeCard({ notice, actions }) { + const [imageUrl, setImageUrl] = useState("https://http.cat/404.jpg"); + const [categoryLabel, setCategoryLabel] = useState(notice?.category || ""); + + useEffect(() => { + let isMounted = true; + + const loadImage = async () => { + if (!notice?.noticeId) { + return; + } + try { + const image = await getImageByNoticeId(notice.noticeId); + if (isMounted) { + setImageUrl(image); + } else if (typeof image === "string" && image.startsWith("blob:")) { + URL.revokeObjectURL(image); + } + } catch { + if (isMounted) { + setImageUrl("https://http.cat/404.jpg"); + } + } + }; + + loadImage(); + + return () => { + isMounted = false; + }; + }, [notice?.noticeId]); + + useEffect(() => { + return () => { + if (typeof imageUrl === "string" && imageUrl.startsWith("blob:")) { + URL.revokeObjectURL(imageUrl); + } + }; + }, [imageUrl]); + + useEffect(() => { + let isMounted = true; + + const resolveCategoryLabel = async () => { + if (!notice?.category) { + setCategoryLabel(""); + return; + } + + const map = await getCategoryLabelMap(); + if (isMounted) { + setCategoryLabel(map.get(String(notice.category)) || notice.category); + } + }; + + resolveCategoryLabel(); + + return () => { + isMounted = false; + }; + }, [notice?.category]); + + if (!notice) { + return null; + } + + return ( +
+ + {notice.title} + +
+
+ + {notice.title} + + + {notice.price} zl + +
+

{categoryLabel}

+ {/* {notice.status && ( + + {notice.status} + + )} */} + {actions ?
{actions}
: null} +
+
+ ); +} diff --git a/src/components/NoticeForm.jsx b/src/components/NoticeForm.jsx new file mode 100644 index 0000000..0f1c5da --- /dev/null +++ b/src/components/NoticeForm.jsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from "react"; +import { attributes } from "../data/attributesData"; + +const defaultErrors = { + title: false, + description: false, + price: false, + category: false, +}; + +export default function NoticeForm({ + initialValues, + categories, + submitLabel, + isSubmitting, + status = "ACTIVE", + onSubmit, +}) { + const [title, setTitle] = useState(initialValues?.title ?? ""); + const [description, setDescription] = useState(initialValues?.description ?? ""); + const [price, setPrice] = useState(initialValues?.price?.toString() ?? ""); + const [category, setCategory] = useState(initialValues?.category ?? ""); + const [images, setImages] = useState(initialValues?.images ?? []); + const [selectedAttributes, setSelectedAttributes] = useState( + initialValues?.selectedAttributes ?? {} + ); + const [errors, setErrors] = useState(defaultErrors); + + const previewUrls = useMemo( + () => + images.map((img) => (img instanceof File ? URL.createObjectURL(img) : img)), + [images] + ); + + useEffect(() => { + return () => { + previewUrls.forEach((url) => { + if (url.startsWith("blob:")) { + URL.revokeObjectURL(url); + } + }); + }; + }, [previewUrls]); + + const handleFilesChange = (event) => { + const fileList = Array.from(event.target.files ?? []); + setImages(fileList); + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + + const nextErrors = { + title: !title.trim(), + description: !description.trim(), + price: !price.toString().trim(), + category: !category.trim(), + }; + setErrors(nextErrors); + + if (Object.values(nextErrors).some(Boolean)) { + return; + } + + const formattedAttributes = Object.entries(selectedAttributes) + .filter(([, value]) => value) + .map(([name, value]) => ({ name, value })); + + await onSubmit({ + title: title.trim(), + description: description.trim(), + price: price.toString().trim(), + category, + status, + image: images, + attributes: formattedAttributes, + }); + }; + + return ( +
+
+ + +

+ Pierwsze zdjecie bedzie zdjeciem glownym. +

+ {previewUrls.length > 0 ? ( +
+ {previewUrls.map((url, index) => ( + {`Podglad + ))} +
+ ) : null} +
+ +
+ + setTitle(event.target.value)} + value={title} + /> +
+ +
+ +