Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 122 additions & 18 deletions app/admin/add-barometer/add-manufacturer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import React, { useEffect } from 'react'
import { useEffect, useCallback, useState } from 'react'
import {
Box,
Button,
Expand All @@ -11,15 +11,18 @@ import {
ActionIcon,
Tooltip,
MultiSelect,
FileButton,
CloseButton,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useForm } from '@mantine/form'
import { isLength } from 'validator'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { IconSquareRoundedPlus } from '@tabler/icons-react'
import { IconSquareRoundedPlus, IconPhotoPlus } from '@tabler/icons-react'
import { showError, showInfo } from '@/utils/notification'
import { addManufacturer } from '@/utils/fetch'
import { useBarometers } from '@/app/hooks/useBarometers'
import { generateIcon } from '@/utils/misc'

interface AddManufacturerProps {
onAddManufacturer: (newId: string) => void
Expand All @@ -31,6 +34,7 @@ interface Form {
city: string
countries: number[]
description: string
icon?: string | null
}

export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) {
Expand All @@ -44,6 +48,7 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) {
city: '',
countries: [],
description: '',
icon: null,
},
validate: {
name: val =>
Expand All @@ -56,6 +61,7 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) {
: 'First name should be longer than 2 and shorter than 100 symbols',
city: val =>
isLength(val ?? '', { max: 100 }) ? null : 'City should be shorter that 100 symbols',
icon: val => (isLength(val ?? '', { min: 1 }) ? null : 'Icon should be selected'),
},
})
const queryClient = useQueryClient()
Expand All @@ -76,24 +82,49 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) {
})

useEffect(() => {
if (opened) form.reset()
if (opened) {
form.reset()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened])

const handleIconChange = useCallback(
async (file: File | null) => {
if (!file) {
form.setFieldValue('icon', null)
return
}
form.clearFieldError('icon')
try {
const fileUrl = URL.createObjectURL(file)
const iconData = await generateIcon(fileUrl, 50)
URL.revokeObjectURL(fileUrl)
form.setFieldValue('icon', iconData)
} catch (error) {
form.setFieldValue('icon', null)
form.setFieldError(
'icon',
error instanceof Error ? error.message : 'Image cannot be opened',
)
}
},
[form],
)

const handleSubmit = useCallback(
async (values: Form) => {
mutate({
...values,
countries: values.countries.map(id => ({ id })),
})
},
[mutate],
)

return (
<>
<Modal opened={opened} onClose={close} centered>
<Box
flex={1}
component="form"
onSubmit={form.onSubmit((values, event) => {
// prevent bubbling up to parent form
event?.stopPropagation()
mutate({
...values,
countries: values.countries.map(id => ({ id })),
})
})}
>
<Box component="form" onSubmit={form.onSubmit(handleSubmit)}>
<Title mb="lg" order={3}>
Add Manufacturer
</Title>
Expand All @@ -120,9 +151,19 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) {
label="Description"
{...form.getInputProps('description')}
/>
<Button mt="lg" type="submit" color="dark" variant="outline">
Add Manufacturer
</Button>
<div className="mt-4 flex justify-between">
<div className="flex items-end">
<Button
type="button"
color="dark"
variant="outline"
onClick={() => form.onSubmit(handleSubmit)()}
>
Add Manufacturer
</Button>
</div>
<IconUpload onFileChange={handleIconChange} errorMsg={form.errors.icon} />
</div>
</Box>
</Modal>

Expand All @@ -134,3 +175,66 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) {
</>
)
}

interface IconUploadProps {
onFileChange: (file: File | null) => void
errorMsg: React.ReactNode
}

const IconUpload = ({ onFileChange, errorMsg }: IconUploadProps) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null)

const handleFileSelect = (selectedFile: File | null) => {
onFileChange(selectedFile)

if (selectedFile) {
const url = URL.createObjectURL(selectedFile)
setPreviewUrl(url)
} else {
setPreviewUrl(null)
}
}

useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl)
}
}
}, [previewUrl])

return (
<div className="flex flex-col items-end gap-1">
{previewUrl && (
<div className="relative w-fit">
<CloseButton
className="!absolute -right-2 -top-2 !rounded-full !bg-white"
size="xs"
onClick={() => handleFileSelect(null)}
aria-label="Remove icon"
/>
<img
src={previewUrl}
alt="Icon preview"
className="h-12 w-12 rounded border object-cover"
/>
</div>
)}
<div className="flex flex-col items-end gap-1">
<FileButton onChange={handleFileSelect} accept="image/*">
{props => (
<Button
{...props}
variant="default"
color="dark.4"
leftSection={<IconPhotoPlus size={16} />}
>
Select Icon
</Button>
)}
</FileButton>
{errorMsg && <div className="text-xs text-red-500">{errorMsg}</div>}
</div>
</div>
)
}
21 changes: 18 additions & 3 deletions app/api/v2/manufacturers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import { cleanObject, getBrandSlug, trimTrailingSlash } from '@/utils/misc'
import { DEFAULT_PAGE_SIZE } from '../parameters'
import { FrontRoutes } from '@/utils/routes-front'

interface ManufacturerDTO extends Manufacturer {
interface ManufacturerDTO extends Omit<Manufacturer, 'icon'> {
successors?: { id: string }[]
countries?: { id: number }[]
images?: { id: string; url: string; blurData: string }[]
icon?: string | null
}
/**
* Retrieve a list of all Manufacturers
Expand All @@ -36,13 +37,20 @@ export async function GET(req: NextRequest) {
*/
export const POST = withPrisma(async (prisma, req: NextRequest) => {
try {
const { successors, countries, images, ...manufData }: ManufacturerDTO = await req
const { successors, countries, images, icon, ...manufData }: ManufacturerDTO = await req
.json()
.then(cleanObject)

// Convert base64 to Buffer
const iconBuffer =
icon && typeof icon === 'string'
? Buffer.from(icon.replace(/^data:image\/\w+;base64,/, ''), 'base64')
: null

const { id, slug } = await prisma.manufacturer.create({
data: {
...manufData,
icon: iconBuffer,
slug: getBrandSlug(manufData.name, manufData.firstName),
...(successors
? {
Expand Down Expand Up @@ -85,14 +93,20 @@ export const POST = withPrisma(async (prisma, req: NextRequest) => {
*/
export const PUT = withPrisma(async (prisma, req: NextRequest) => {
try {
const { successors, countries, images, ...manufData }: ManufacturerDTO = await req
const { successors, countries, images, icon, ...manufData }: ManufacturerDTO = await req
.json()
.then(data =>
// replace empty strings with NULLs
traverse.map(data, function map(node) {
if (node === '') this.update(null)
}),
)

// Convert base64 to Buffer
const iconBuffer =
icon && typeof icon === 'string'
? Buffer.from(icon.replace(/^data:image\/\w+;base64,/, ''), 'base64')
: null
const manufacturer = await prisma.manufacturer.findUnique({ where: { id: manufData.id } })
if (!manufacturer) {
return NextResponse.json({ message: 'Manufacturer not found' }, { status: 404 })
Expand All @@ -111,6 +125,7 @@ export const PUT = withPrisma(async (prisma, req: NextRequest) => {
where: { id: manufacturer.id },
data: {
...manufData,
icon: iconBuffer,
...(successors
? {
successors: {
Expand Down
61 changes: 28 additions & 33 deletions app/brands/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { withPrisma } from '@/prisma/prismaClient'
import { FrontRoutes } from '@/utils/routes-front'
import { title } from '../metadata'
import { DynamicOptions } from '../types'
import customImageLoader from '@/utils/image-loader'

export const dynamic: DynamicOptions = 'force-static'

Expand All @@ -16,7 +15,7 @@ export const metadata: Metadata = {
}

const getBrandsByCountry = withPrisma(async prisma => {
const brandsByCountry = await prisma.country.findMany({
return prisma.country.findMany({
orderBy: {
name: 'asc',
},
Expand Down Expand Up @@ -50,17 +49,11 @@ const getBrandsByCountry = withPrisma(async prisma => {
},
take: 1,
},
icon: true,
},
},
},
})
return brandsByCountry.map(country => ({
...country,
manufacturers: country.manufacturers.map(({ barometers, ...brand }) => ({
...brand,
image: barometers.at(0)?.images.at(0),
})),
}))
})

const BrandsOfCountry = ({
Expand All @@ -69,37 +62,39 @@ const BrandsOfCountry = ({
country: Awaited<ReturnType<typeof getBrandsByCountry>>[number]
}) => {
const width = 32
const quality = 75
return (
<div className="mb-5 mr-4">
<Title order={3} className="!mb-5 border-b border-solid border-neutral-400 px-5 py-[0.1rem]">
{country.name}
</Title>

<div className="flex flex-col gap-4">
{country.manufacturers.map(({ id, firstName, name, slug, image }) => (
<Link className="w-fit" key={id} href={FrontRoutes.Brands + slug}>
<div className="flex flex-nowrap items-center gap-3">
{image ? (
<Image
unoptimized
width={width}
height={width}
alt={name}
loading="lazy"
src={customImageLoader({ src: image.url, width, quality })}
blurDataURL={image.blurData}
className="h-8 w-8 object-contain"
/>
) : (
<IconCircleArrowUp size={32} />
)}
<p className="w-fit font-medium capitalize">
{name + (firstName ? `, ${firstName}` : '')}
</p>
</div>
</Link>
))}
{country.manufacturers.map(({ id, firstName, name, slug, icon }) => {
const base64 = icon ? Buffer.from(icon).toString('base64') : null
const image = base64 ? `data:image/png;base64,${base64}` : null
return (
<Link className="w-fit" key={id} href={FrontRoutes.Brands + slug}>
<div className="flex flex-nowrap items-center gap-3">
{image ? (
<Image
unoptimized
width={width}
height={width}
alt={name}
loading="lazy"
src={image}
className="h-8 w-8 object-contain"
/>
) : (
<IconCircleArrowUp size={32} />
)}
<p className="w-fit font-medium capitalize">
{name + (firstName ? `, ${firstName}` : '')}
</p>
</div>
</Link>
)
})}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Manufacturer" ADD COLUMN "icon" BYTEA;
2 changes: 1 addition & 1 deletion prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DATABASE_URL_UNPOOLED")
}

model Image {
Expand Down Expand Up @@ -121,6 +120,7 @@ model Manufacturer {
updatedAt DateTime @updatedAt
barometers Barometer[]
images Image[] @relation("ManufacturerImages")
icon Bytes?
// self-referencing relations
// some brands may have multiple successors and/or predecessors
successors Manufacturer[] @relation("ManufacturerSuccessors")
Expand Down
Loading
Loading