diff --git a/app/admin/add-barometer/add-manufacturer.tsx b/app/admin/add-barometer/add-manufacturer.tsx index 13c67bc9..3b011eda 100644 --- a/app/admin/add-barometer/add-manufacturer.tsx +++ b/app/admin/add-barometer/add-manufacturer.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useEffect } from 'react' +import { useEffect, useCallback, useState } from 'react' import { Box, Button, @@ -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 @@ -31,6 +34,7 @@ interface Form { city: string countries: number[] description: string + icon?: string | null } export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) { @@ -44,6 +48,7 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) { city: '', countries: [], description: '', + icon: null, }, validate: { name: val => @@ -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() @@ -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 ( <> - { - // prevent bubbling up to parent form - event?.stopPropagation() - mutate({ - ...values, - countries: values.countries.map(id => ({ id })), - }) - })} - > + Add Manufacturer @@ -120,9 +151,19 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) { label="Description" {...form.getInputProps('description')} /> - +
+
+ +
+ +
@@ -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(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 ( +
+ {previewUrl && ( +
+ handleFileSelect(null)} + aria-label="Remove icon" + /> + Icon preview +
+ )} +
+ + {props => ( + + )} + + {errorMsg &&
{errorMsg}
} +
+
+ ) +} diff --git a/app/api/v2/manufacturers/route.ts b/app/api/v2/manufacturers/route.ts index b07157d7..0e7ac1a5 100644 --- a/app/api/v2/manufacturers/route.ts +++ b/app/api/v2/manufacturers/route.ts @@ -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 { successors?: { id: string }[] countries?: { id: number }[] images?: { id: string; url: string; blurData: string }[] + icon?: string | null } /** * Retrieve a list of all Manufacturers @@ -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 ? { @@ -85,7 +93,7 @@ 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 @@ -93,6 +101,12 @@ export const PUT = withPrisma(async (prisma, req: NextRequest) => { 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 }) @@ -111,6 +125,7 @@ export const PUT = withPrisma(async (prisma, req: NextRequest) => { where: { id: manufacturer.id }, data: { ...manufData, + icon: iconBuffer, ...(successors ? { successors: { diff --git a/app/brands/page.tsx b/app/brands/page.tsx index 67e8d408..5cef439f 100644 --- a/app/brands/page.tsx +++ b/app/brands/page.tsx @@ -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' @@ -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', }, @@ -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 = ({ @@ -69,7 +62,6 @@ const BrandsOfCountry = ({ country: Awaited>[number] }) => { const width = 32 - const quality = 75 return (
@@ -77,29 +69,32 @@ const BrandsOfCountry = ({
- {country.manufacturers.map(({ id, firstName, name, slug, image }) => ( - -
- {image ? ( - {name} - ) : ( - - )} -

- {name + (firstName ? `, ${firstName}` : '')} -

-
- - ))} + {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 ( + +
+ {image ? ( + {name} + ) : ( + + )} +

+ {name + (firstName ? `, ${firstName}` : '')} +

+
+ + ) + })}
) diff --git a/prisma/migrations/20250621212345_add_manufacturer_icon/migration.sql b/prisma/migrations/20250621212345_add_manufacturer_icon/migration.sql new file mode 100644 index 00000000..ee5635ed --- /dev/null +++ b/prisma/migrations/20250621212345_add_manufacturer_icon/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Manufacturer" ADD COLUMN "icon" BYTEA; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57fd..044d57cd 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44690670..5c22f64e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { datasource db { provider = "postgresql" url = env("DATABASE_URL") - directUrl = env("DATABASE_URL_UNPOOLED") } model Image { @@ -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") diff --git a/db-backup.ts b/scripts/db-backup.ts similarity index 86% rename from db-backup.ts rename to scripts/db-backup.ts index 4ad498b4..1aec863d 100644 --- a/db-backup.ts +++ b/scripts/db-backup.ts @@ -4,7 +4,7 @@ import { ensureDirSync } from 'fs-extra' import dotenv from 'dotenv' import path from 'path' -dotenv.config({ path: path.resolve(__dirname, '.env') }) +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }) const url = process.env.DATABASE_URL if (!url) throw new Error('No DATABASE_URL in .env') @@ -12,7 +12,7 @@ if (!url) throw new Error('No DATABASE_URL in .env') ensureDirSync('backups') const time = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0] -const projectRoot = path.resolve(__dirname) +const projectRoot = path.resolve(__dirname, '..') const backupDir = path.join(projectRoot, 'backups') const file = path.join(backupDir, `backup_${time}.dump`) diff --git a/utils/fetch.ts b/utils/fetch.ts index 5a4cd0df..01273e7c 100644 --- a/utils/fetch.ts +++ b/utils/fetch.ts @@ -107,7 +107,9 @@ export async function deleteManufacturer(slug: string) { }) } export async function addManufacturer( - manufacturer: { countries: { id: number }[] } & Partial, + manufacturer: { countries: { id: number }[] } & Partial> & { + icon?: string | null + }, ): Promise<{ id: string }> { const res = await fetch(ApiRoutes.Manufacturers, { method: 'POST', diff --git a/utils/misc.ts b/utils/misc.ts index d47b5ac0..413600c5 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -43,6 +43,19 @@ export async function handleApiError(res: Response): Promise { } } +function loadImage(imgUrl: string): Promise { + // Load original full-size image + const image = new Image() + image.crossOrigin = 'Anonymous' // Enable CORS to fetch the image + image.src = imgUrl + + // Wait for the image to load + return new Promise((resolve, reject) => { + image.onload = () => resolve(image) + image.onerror = reject + }) +} + /** * Creates a blurred thumbnail from an image URL and returns it as a Base64-encoded JPEG string. * @@ -58,17 +71,7 @@ export async function getThumbnailBase64( blurRadius: number = 1, targetHeight: number = 32, // Fixed height ): Promise { - // Load original full-size image - const image = new Image() - image.crossOrigin = 'Anonymous' // Enable CORS to fetch the image - image.src = imgUrl - - // Wait for the image to load - await new Promise((resolve, reject) => { - image.onload = resolve - image.onerror = reject - }) - + const image = await loadImage(imgUrl) // Calculate proportional width based on the fixed height const aspectRatio = image.width / image.height const targetWidth = targetHeight * aspectRatio @@ -97,6 +100,25 @@ export async function getThumbnailBase64( return canvas.toDataURL('image/png') // Use PNG for lossless quality } +export async function generateIcon( + imgUrl?: string, + size: number = 32, + backgroundColor: string = '#efefef', +): Promise { + if (!imgUrl) return null + const image = await loadImage(imgUrl) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Canvas context not available') + canvas.width = size + canvas.height = size + ctx.fillStyle = backgroundColor + ctx.fillRect(0, 0, size, size) + ctx.drawImage(image, 0, 0, size, size) + + return canvas.toDataURL('image/png') +} + /** * Removes slashes at the end of API route URL */