diff --git a/.gitignore b/.gitignore index 66316e7c..d74fd18e 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,5 @@ dist public/uploads .vercel +# webstorrm .idea diff --git a/actions/barometer-types.ts b/actions/barometer-types.ts new file mode 100644 index 00000000..43b215c7 --- /dev/null +++ b/actions/barometer-types.ts @@ -0,0 +1,18 @@ +'use server' + +import { connectMongoose } from '@/utils/mongoose' +import BarometerType, { IBarometerType } from '@/models/type' + +export async function getType(type: string): Promise { + await connectMongoose() + const barometerType = await BarometerType.findOne({ name: { $regex: type, $options: 'i' } }) + if (!barometerType) throw new Error('Unknown barometer type') + return { ...barometerType.toObject(), _id: String(barometerType._id) } +} + +export async function listTypes(): Promise { + await connectMongoose() + return (await BarometerType.find().sort({ order: 1 })).map(res => + res.toObject({ flattenObjectIds: true }), + ) +} diff --git a/actions/barometers.ts b/actions/barometers.ts new file mode 100644 index 00000000..b201b349 --- /dev/null +++ b/actions/barometers.ts @@ -0,0 +1,104 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { connectMongoose } from '@/utils/mongoose' +import Barometer, { IBarometer } from '@/models/barometer' +import { cleanObject, slug as slugify, parseDate } from '@/utils/misc' +import { SortValue } from '@/app/collection/types/[type]/types' +import BarometerType from '@/models/type' +import Manufacturer from '@/models/manufacturer' +import '@/models/condition' + +/** + * Related tables to include in database response + */ +const dependencies = ['type', 'condition', 'manufacturer'] + +export async function getBarometer(slug: string): Promise { + await connectMongoose() + const barometer = await Barometer.findOne({ slug }).populate(dependencies) + if (!barometer) throw new Error('Unknown slug') + return barometer.toObject({ flattenObjectIds: true }) +} + +/** + * Server function which creates new barometer in the database and generates corresponding page + * @returns created barometer slug + */ +export async function createBarometer(barometerData: IBarometer) { + await connectMongoose() + const cleanData = cleanObject(barometerData) + const slug = slugify(cleanData.name) + cleanData.slug = slug + const newBarometer = new Barometer(cleanData) + await newBarometer.save() + revalidatePath(`/collection/items/${slug}`) + return slug +} + +/** + * Server function which updates existing barometer with new data and regenerates corresponding page + * @returns updated barometer slug (may differ from the original) + */ +export async function updateBarometer(barometerData: IBarometer): Promise { + await connectMongoose() + const slug = slugify(barometerData.name) + barometerData.slug = slug + await Manufacturer.findByIdAndUpdate(barometerData.manufacturer?._id, barometerData.manufacturer) + const updatedBarometer = await Barometer.findByIdAndUpdate(barometerData._id, barometerData) + if (!updatedBarometer) throw new Error('Barometer not found') + revalidatePath(`/collection/items/${slug}`) + return slug +} + +export async function listBarometers(options?: { + type?: string + sort?: SortValue + limit?: number +}): Promise { + await connectMongoose() + const sortBy = options?.sort ?? 'date' + if (!options?.type) + return sortBarometers( + await Barometer.find() + .limit(options?.limit ?? 0) + .populate(dependencies), + sortBy, + ) + const barometerType = await BarometerType.findOne({ + name: { $regex: new RegExp(`^${options.type}$`, 'i') }, + }) + if (!barometerType) throw new Error('Unknown barometer type') + return sortBarometers( + ( + await Barometer.find({ type: barometerType._id }) + .limit(options.limit ?? 0) + .populate(dependencies) + ).map(res => res.toObject({ flattenObjectIds: true })), + sortBy, + ) +} + +function sortBarometers(barometers: IBarometer[], sortBy: SortValue | null): IBarometer[] { + return barometers.toSorted((a, b) => { + switch (sortBy) { + case 'manufacturer': + return (a.manufacturer?.name ?? '').localeCompare(b.manufacturer?.name ?? '') + case 'name': + return a.name.localeCompare(b.name) + case 'date': { + if (!a.dating || !b.dating) return 0 + const yearA = parseDate(a.dating)?.[0] + const yearB = parseDate(b.dating)?.[0] + if (!yearA || !yearB) return 0 + const dateA = new Date(yearA, 0, 1).getTime() + const dateB = new Date(yearB, 0, 1).getTime() + return dateA - dateB + } + case 'cat-no': + return a.collectionId.localeCompare(b.collectionId) + default: + return 0 + } + }) +} diff --git a/actions/conditions.ts b/actions/conditions.ts new file mode 100644 index 00000000..6c465872 --- /dev/null +++ b/actions/conditions.ts @@ -0,0 +1,10 @@ +'use server' + +import { connectMongoose } from '@/utils/mongoose' +import BarometerCondition, { IBarometerCondition } from '@/models/condition' + +export async function listConditions(): Promise { + await connectMongoose() + const conditions = await BarometerCondition.find() + return conditions.map(condition => condition.toObject({ flattenObjectIds: true })) +} diff --git a/actions/images.ts b/actions/images.ts new file mode 100644 index 00000000..f6e98113 --- /dev/null +++ b/actions/images.ts @@ -0,0 +1,46 @@ +'use server' + +import path from 'path' +import { v4 as uuid } from 'uuid' +import { GetSignedUrlConfig, Storage } from '@google-cloud/storage' + +const decodedPrivateKey = Buffer.from(process.env.GCP_PRIVATE_KEY, 'base64').toString('utf-8') +const storage = new Storage({ + projectId: process.env.GCP_PROJECT_ID, + credentials: { + client_email: process.env.GCP_CLIENT_EMAIL, + private_key: decodedPrivateKey, + }, +}) +const bucket = storage.bucket(process.env.GCP_BUCKET_NAME) + +export async function uploadImages(files: { fileName: string; contentType: string }[]) { + const signedUrls = await Promise.all( + files.map(async ({ fileName, contentType }) => { + // give unique names to files + const extension = path.extname(fileName).toLowerCase() + const newFileName = uuid() + extension + const options: GetSignedUrlConfig = { + version: 'v4', + action: 'write', + expires: Date.now() + 15 * 60 * 1000, // 15 min + contentType, + } + const cloudFile = bucket.file(newFileName) + // generate signed URL for each file + const [signedUrl] = await cloudFile.getSignedUrl(options) + return { + signed: signedUrl, + public: cloudFile.publicUrl(), + } + }), + ) + return signedUrls +} + +// delete file from google cloud storage +export async function deleteImage(url?: string) { + if (!url) throw new Error('Unknown image file') + const file = bucket.file(url) + await file.delete() +} diff --git a/actions/manufacturers.ts b/actions/manufacturers.ts new file mode 100644 index 00000000..e07f5a2c --- /dev/null +++ b/actions/manufacturers.ts @@ -0,0 +1,25 @@ +'use server' + +import { connectMongoose } from '@/utils/mongoose' +import Manufacturer, { IManufacturer } from '@/models/manufacturer' +import { cleanObject } from '@/utils/misc' + +export async function listManufacturers(): Promise { + await connectMongoose() + const conditions = await Manufacturer.find() + return conditions.map(obj => obj.toObject({ flattenObjectIds: true })) +} + +export async function setManufacturer(manufData: IManufacturer): Promise { + await connectMongoose() + const cleanData = cleanObject(manufData) + const newManufacturer = new Manufacturer(cleanData) + await newManufacturer.save() + return newManufacturer.toObject({ flattenObjectIds: true }) +} + +export async function deleteManufacturer(id: string) { + await connectMongoose() + const deletedManufacturer = await Manufacturer.findByIdAndDelete(id) + if (!deletedManufacturer) throw new Error('Unknown manufacturer') +} diff --git a/app/admin/components/add-card.tsx b/app/admin/components/add-card.tsx index 6f6313b2..62c0ef21 100644 --- a/app/admin/components/add-card.tsx +++ b/app/admin/components/add-card.tsx @@ -1,22 +1,22 @@ 'use client' +import { useSWRConfig } from 'swr' import { Box, Title, Button, TextInput, Select, Textarea } from '@mantine/core' import { useForm } from '@mantine/form' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import axios, { AxiosError } from 'axios' import { useEffect, useState } from 'react' import { isLength } from 'validator' import { showInfo, showError } from '@/utils/notification' -import { useBarometers } from '@/app/hooks/useBarometers' import { FileUpload } from './file-upload' import { AddManufacturer } from './add-manufacturer' import { Dimensions } from './dimensions' import type { BarometerFormProps } from '../types' -import { barometersApiRoute } from '@/app/constants' +import { useBarometers } from '@/app/hooks/useBarometers' +import { createBarometer } from '@/actions/barometers' export function AddCard() { + const { mutate } = useSWRConfig() const [uploadedImages, setUploadedImages] = useState([]) - const { condition, types, manufacturers } = useBarometers() + const { types, manufacturers, conditions } = useBarometers() const form = useForm({ initialValues: { @@ -35,57 +35,46 @@ export function AddCard() { }, }) - const queryClient = useQueryClient() - const { mutate } = useMutation({ - mutationFn: async (values: BarometerFormProps) => { + const saveBarometer = async (values: BarometerFormProps) => { + try { const barometerWithImages = { ...values, - manufacturer: manufacturers.data.find(({ _id }) => _id === values.manufacturer), + manufacturer: manufacturers?.find(({ _id }) => _id === values.manufacturer), images: uploadedImages.map(image => image.split('/').at(-1)), - } - const { data } = await axios.post(barometersApiRoute, barometerWithImages) - return data - }, - onSuccess: (_, { name }) => { - queryClient.invalidateQueries({ - queryKey: ['barometers'], - }) + } as any + await createBarometer(barometerWithImages) form.reset() setUploadedImages([]) - showInfo(`Added ${name} to the database`) - }, - onError: (error: AxiosError) => { - showError( - (error.response?.data as { message: string })?.message || - error.message || - 'Error adding barometer', - ) - }, - }) + showInfo(`Added ${values.name} to the database`) + } catch (error) { + showError(error instanceof Error ? error.message : 'Error adding barometer') + } + } // set default barometer type useEffect(() => { - if (types.data.length === 0) return - form.setFieldValue('type', String(types.data[0]._id)) + if (types?.length === 0) return + form.setFieldValue('type', String(types?.[0]._id)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [types.data]) + }, [types]) // set default barometer condition useEffect(() => { - if (condition.data.length === 0) return - form.setFieldValue('condition', String(condition.data.at(-1)?._id)) + if (conditions?.length === 0) return + form.setFieldValue('condition', String(conditions?.at(-1)?._id)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [condition.data]) + }, [conditions]) // set default manufacturer useEffect(() => { // if there are no manufacturers or manufacturer is already set, do nothing - if (manufacturers.data.length === 0 || form.values.manufacturer) return - form.setFieldValue('manufacturer', String(manufacturers.data[0]._id)) + if (manufacturers?.length === 0 || form.values.manufacturer) return + form.setFieldValue('manufacturer', String(manufacturers?.[0]._id)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [manufacturers.data]) + }, [manufacturers]) const onAddManufacturer = (id: string) => { + mutate('manufacturers') form.setFieldValue('manufacturer', id) } @@ -94,12 +83,12 @@ export function AddCard() { Add new barometer - mutate(values))}> + saveBarometer(values))}> ({ + data={manufacturers?.map(({ name, _id }) => ({ label: name, value: _id!, }))} @@ -125,7 +114,7 @@ export function AddCard() { /> ({ + data={conditions?.map(({ name, _id }) => ({ label: name, value: String(_id), }))} value={String(form.values.condition?._id)} onChange={id => { - const newCondition = condition.data.find(({ _id }) => _id === id) + const newCondition = conditions?.find(({ _id }) => _id === id) form.setValues({ condition: newCondition }) }} allowDeselect={false} diff --git a/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx b/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx index 2b5ac84a..3221440b 100644 --- a/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx @@ -6,7 +6,6 @@ import { isEqual } from 'lodash' import { useEffect } from 'react' import { useForm } from '@mantine/form' import { useDisclosure } from '@mantine/hooks' -import axios, { AxiosError } from 'axios' import { ActionIcon, Box, @@ -20,8 +19,9 @@ import { } from '@mantine/core' import { IconEdit, IconTrash, IconSquareRoundedPlus } from '@tabler/icons-react' import { IBarometer } from '@/models/barometer' -import { barometerRoute, barometersApiRoute } from '@/app/constants' +import { barometerRoute } from '@/app/constants' import { showError, showInfo } from '@/utils/notification' +import { updateBarometer } from '@/actions/barometers' interface DimFormProps { dimensions: IBarometer['dimensions'] @@ -49,7 +49,7 @@ export default function DimensionEdit({ barometer }: DimensionEditProps) { if (opened) form.reset() }, [opened]) - const updateBarometer = async ({ dimensions }: DimFormProps) => { + const update = async ({ dimensions }: DimFormProps) => { try { if (isEqual(dimensions, barometer.dimensions)) { close() @@ -60,18 +60,12 @@ export default function DimensionEdit({ barometer }: DimensionEditProps) { // keep non-empty entries dimensions: dimensions?.filter(({ dim }) => dim), } - const { data } = await axios.put(barometersApiRoute, updatedBarometer) + const slug = await updateBarometer(updatedBarometer) showInfo(`${barometer.name} updated`, 'Success') close() - window.location.href = barometerRoute + (data.slug ?? '') + window.location.href = barometerRoute + slug } catch (error) { - if (error instanceof AxiosError) { - showError( - (error.response?.data as { message: string })?.message || - error.message || - 'Error updating barometer', - ) - } + showError(error instanceof Error ? error.message : 'Error updating dimensions') } } const addDimension = () => { @@ -98,7 +92,7 @@ export default function DimensionEdit({ barometer }: DimensionEditProps) { tt="capitalize" styles={{ title: { fontSize: '1.5rem', fontWeight: 500 } }} > - + {form.values.dimensions?.map((_, i) => ( diff --git a/app/collection/items/[slug]/components/edit-fields/images-edit.tsx b/app/collection/items/[slug]/components/edit-fields/images-edit.tsx index 464b3759..48e0ba2b 100644 --- a/app/collection/items/[slug]/components/edit-fields/images-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/images-edit.tsx @@ -25,17 +25,12 @@ import { } from '@dnd-kit/sortable' import { useEffect, useState } from 'react' import { useDisclosure } from '@mantine/hooks' -import axios, { AxiosError } from 'axios' import { IBarometer } from '@/models/barometer' import sx from './styles.module.scss' -import { - barometerRoute, - barometersApiRoute, - googleStorageImagesFolder, - imageUploadApiRoute, -} from '@/app/constants' -import { FileDto, UrlDto } from '@/app/api/barometers/upload/images/types' +import { barometerRoute, googleStorageImagesFolder } from '@/app/constants' import { showError, showInfo } from '@/utils/notification' +import { updateBarometer } from '@/actions/barometers' +import { deleteImage, uploadImages } from '@/actions/images' interface ImagesEditProps extends UnstyledButtonProps { size?: string | number | undefined @@ -45,15 +40,6 @@ interface FormProps { images: string[] } -async function deleteFromStorage(img: string) { - // delete image file from google storage - await axios.delete(imageUploadApiRoute, { - params: { - fileName: img, - }, - }) -} - function SortableImage({ image, handleDelete, @@ -133,25 +119,18 @@ export function ImagesEdit({ barometer, size, ...props }: ImagesEditProps) { } setIsUploading(true) try { - // erase deleted images const extraFiles = barometer.images?.filter(img => !form.values.images.includes(img)) - if (extraFiles) await Promise.all(extraFiles?.map(deleteFromStorage)) + if (extraFiles) await Promise.all(extraFiles?.map(deleteImage)) const updatedBarometer: IBarometer = { ...barometer, images: form.getValues().images, } - const { data } = await axios.put(barometersApiRoute, updatedBarometer) + const slug = await updateBarometer(updatedBarometer) showInfo(`${barometer.name} updated`, 'Success') close() - window.location.href = barometerRoute + (data.slug ?? '') + window.location.href = barometerRoute + slug } catch (error) { - if (error instanceof AxiosError) { - showError( - (error.response?.data as { message: string })?.message || - error.message || - 'Error updating barometer', - ) - } + showError(error instanceof Error ? error.message : 'Error uploading images') } finally { setIsUploading(false) } @@ -159,66 +138,35 @@ export function ImagesEdit({ barometer, size, ...props }: ImagesEditProps) { /** * Upload images to google storage */ - const uploadImages = async (files: File[]) => { + const googleUploadImages = async (files: File[]) => { if (!files || !Array.isArray(files) || files.length === 0) return setIsUploading(true) - try { - const { - data: { urls }, - } = await axios.post( - imageUploadApiRoute, - { - files: files.map(file => ({ - fileName: file.name, - contentType: file.type, - })), - } as FileDto, - { headers: { 'Content-Type': 'application/json' } }, - ) - // upload all files concurrently - await Promise.all( - urls.map(async ({ signed }, index) => { - const file = files[index] - await axios.put(signed, file, { - headers: { - 'Content-Type': file.type, - }, - }) - }), - ) - // extracting file names from URLs - const newImages = urls - .map(url => new URL(url.public).pathname.split('/').at(-1) ?? '') - .filter(url => Boolean(url)) - form.setFieldValue('images', prev => [...prev, ...newImages]) - } catch (error) { - const defaultErrMsg = 'Error uploading files' - if (error instanceof AxiosError) { - showError((error.response?.data as { message?: string })?.message || defaultErrMsg) - return - } - showError(error instanceof Error ? error.message : defaultErrMsg) - } finally { - setIsUploading(false) - } + const urls = await uploadImages( + files.map(({ name, type }) => ({ + fileName: name, + contentType: type, + })), + ) + // upload all files to Google cloud concurrently + await Promise.all( + urls.map(async ({ signed }, i) => { + const file = files[i] + await fetch(signed, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } }) + }), + ) + // extracting file names from URLs + const newImages = urls + .map(url => new URL(url.public).pathname.split('/').at(-1) ?? '') + .filter(url => Boolean(url)) + form.setFieldValue('images', prev => [...prev, ...newImages]) + setIsUploading(false) } const handleDeleteFile = async (img: string) => { setIsUploading(true) - try { - // if the image file was uploaded but not yet added to the barometer - if (!barometer.images?.includes(img)) await deleteFromStorage(img) - form.setFieldValue('images', old => old.filter(file => !file.includes(img))) - } catch (error) { - const defaultErrMsg = 'Error deleting file' - if (error instanceof AxiosError) { - showError((error.response?.data as { message?: string })?.message || defaultErrMsg) - return - } - showError(error instanceof Error ? error.message : defaultErrMsg) - } finally { - setIsUploading(false) - } + if (!barometer.images?.includes(img)) await deleteImage(img) + form.setFieldValue('images', old => old.filter(file => !file.includes(img))) + setIsUploading(false) } const onClose = async () => { @@ -226,7 +174,7 @@ export function ImagesEdit({ barometer, size, ...props }: ImagesEditProps) { try { setIsUploading(true) const extraImages = form.values.images.filter(img => !barometer.images?.includes(img)) - await Promise.all(extraImages.map(deleteFromStorage)) + await Promise.all(extraImages.map(deleteImage)) } catch (error) { // do nothing } finally { @@ -247,7 +195,7 @@ export function ImagesEdit({ barometer, size, ...props }: ImagesEditProps) { - + {fbProps => (