From 5ff2c5222bc4adb52f80c55c426fd86d6c87c3e7 Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Wed, 13 May 2026 00:22:18 +0300 Subject: [PATCH 1/6] Fix uploading assets --- src/actions/forms.ts | 8 ++++++-- src/components/Forms/FormOrder/FormOrderClient.tsx | 1 - .../Forms/FormReviews/FormReviewsClient.tsx | 3 +++ src/lib/CockpitAPI.ts | 13 ++++++++----- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/actions/forms.ts b/src/actions/forms.ts index 31d70be..176ab7c 100644 --- a/src/actions/forms.ts +++ b/src/actions/forms.ts @@ -102,6 +102,7 @@ export async function submitReview( const date = new Date().toISOString().slice(0, 10) const safeName = name.replace(/\s+/g, '_').replace(/[^a-zA-Zа-яёА-ЯЁ0-9_]/g, '') + const targetFolder = '6a00a163c6c8763d26aad9a3' const renamedFiles = photoFiles.map((file, index) => { const ext = file.name.includes('.') ? file.name.split('.').pop() : 'jpg' @@ -111,7 +112,8 @@ export async function submitReview( return new File([file], newName, { type: file.type }) }) - const uploadedAssets = renamedFiles.length > 0 ? await cockpit.uploadAssets(renamedFiles) : [] + const uploadedAssets = + renamedFiles.length > 0 ? await cockpit.uploadAssets(renamedFiles, targetFolder) : [] const result = await cockpit.createItem('reviews', { name, @@ -211,6 +213,7 @@ export async function submitApplication( const date = new Date().toISOString().slice(0, 10) const safeName = name.replace(/\s+/g, '_').replace(/[^a-zA-Zа-яёА-ЯЁ0-9_]/g, '') + const targetFolder = '6a03738dc6c876f8f315ef7f' const renamedFiles = photoFiles.map((file, index) => { const ext = file.name.includes('.') ? file.name.split('.').pop() : 'jpg' @@ -219,7 +222,8 @@ export async function submitApplication( return new File([file], newName, { type: file.type }) }) - const uploadedAssets = renamedFiles.length > 0 ? await cockpit.uploadAssets(renamedFiles) : [] + const uploadedAssets = + renamedFiles.length > 0 ? await cockpit.uploadAssets(renamedFiles, targetFolder) : [] const result = await cockpit.createItem('applications', { name, diff --git a/src/components/Forms/FormOrder/FormOrderClient.tsx b/src/components/Forms/FormOrder/FormOrderClient.tsx index 9bf8604..28f3faa 100644 --- a/src/components/Forms/FormOrder/FormOrderClient.tsx +++ b/src/components/Forms/FormOrder/FormOrderClient.tsx @@ -241,7 +241,6 @@ export default function FormOrderClient({ const formData = new FormData(formRef.current) formData.set('smart-token', token) - // Добавляем файлы из DropZone вручную const files = dropZoneRef.current?.getFiles() ?? [] files.forEach((file) => formData.append('photos', file)) diff --git a/src/components/Forms/FormReviews/FormReviewsClient.tsx b/src/components/Forms/FormReviews/FormReviewsClient.tsx index 6663bb9..135f71e 100644 --- a/src/components/Forms/FormReviews/FormReviewsClient.tsx +++ b/src/components/Forms/FormReviews/FormReviewsClient.tsx @@ -58,6 +58,9 @@ export default function FormReviewsClient({ agreementUrl, policyUrl }: Props): J const formData = new FormData(formRef.current) formData.set('smart-token', token) + const files = dropZoneRef.current?.getFiles() ?? [] + files.forEach((file) => formData.append('photos', file)) + startTransition(() => { formAction(formData) }) diff --git a/src/lib/CockpitAPI.ts b/src/lib/CockpitAPI.ts index 3a9c5ca..84517c6 100644 --- a/src/lib/CockpitAPI.ts +++ b/src/lib/CockpitAPI.ts @@ -96,14 +96,17 @@ class CockpitClient { } } - private async uploadSingleAsset(file: File): Promise | null> { + private async uploadSingleAsset( + file: File, + folder?: string | null, + ): Promise | null> { const formData = new FormData() - const folder = '6a00a163c6c8763d26aad9a3' formData.append('file', file, file.name) try { - const url = `${this.baseUrl.replace(/\/$/, '')}/api/upload?folder=${folder}` + const url = `${this.baseUrl.replace(/\/$/, '')}/api/upload` + (folder ? `?folder=${folder}` : '') + console.log(`Uploading ${url} to ${folder}`, formData) const response = await fetch(url, { method: 'POST', headers: { 'api-key': this.apiKey }, @@ -127,11 +130,11 @@ class CockpitClient { } } - async uploadAssets(files: File[]): Promise[]> { + async uploadAssets(files: File[], folder?: string | null): Promise[]> { const assets: Record[] = [] for (const file of files) { - const asset = await this.uploadSingleAsset(file) + const asset = await this.uploadSingleAsset(file, folder) if (asset) { assets.push(asset) } From a588bcfb928f4d8effa587da742c532cbe8e096b Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Wed, 13 May 2026 00:23:14 +0300 Subject: [PATCH 2/6] Fix uploading assets --- src/lib/CockpitAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/CockpitAPI.ts b/src/lib/CockpitAPI.ts index 84517c6..343a938 100644 --- a/src/lib/CockpitAPI.ts +++ b/src/lib/CockpitAPI.ts @@ -106,7 +106,7 @@ class CockpitClient { try { const url = `${this.baseUrl.replace(/\/$/, '')}/api/upload` + (folder ? `?folder=${folder}` : '') - console.log(`Uploading ${url} to ${folder}`, formData) + const response = await fetch(url, { method: 'POST', headers: { 'api-key': this.apiKey }, From c5cde389542187056c586aaf535bb5b7530798e7 Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Wed, 13 May 2026 09:06:24 +0300 Subject: [PATCH 3/6] Fix forms --- .../FormCalculation/FormCalculationClient.tsx | 74 ++++++------------- .../Forms/FormContacts/FormContactsClient.tsx | 8 +- src/components/Forms/FormOrder/FormOrder.tsx | 2 +- .../Forms/FormOrder/FormOrderClient.tsx | 16 ++-- .../Forms/FormReviews/FormReviewsClient.tsx | 6 +- src/const/const.ts | 6 ++ src/lib/schemas.ts | 6 +- src/types/types.ts | 4 + 8 files changed, 50 insertions(+), 72 deletions(-) diff --git a/src/components/Forms/FormCalculation/FormCalculationClient.tsx b/src/components/Forms/FormCalculation/FormCalculationClient.tsx index ad8daff..e8b3dbb 100644 --- a/src/components/Forms/FormCalculation/FormCalculationClient.tsx +++ b/src/components/Forms/FormCalculation/FormCalculationClient.tsx @@ -3,7 +3,8 @@ import { JSX, PointerEvent, KeyboardEvent, useEffect, useRef, useState, useMemo } from 'react' import formStyles from '../../../styles/modules/form.module.scss' import clsx from 'clsx' -import type { PriceItem } from '@/types/types' +import type { PriceItem, GoldTypeValue } from '@/types/types' +import { GOLD_TYPE_OPTIONS } from '@/const/const' type Props = { prices: PriceItem[] @@ -12,7 +13,7 @@ type Props = { export default function FormCalculationClient({ prices: initialPrices }: Props): JSX.Element { const prices: PriceItem[] = useMemo(() => initialPrices || [], [initialPrices]) const [selectedId, setSelectedId] = useState('') - const [goldType, setGoldType] = useState<'without_gold' | 'all' | 'halo'>('without_gold') + const [goldType, setGoldType] = useState('without_gold') const [calculated, setCalculated] = useState('') const isLoading = false const error: string | null = @@ -239,56 +240,25 @@ export default function FormCalculationClient({ prices: initialPrices }: Props): role="radiogroup" aria-label="Тип золочения" > - - - - - + {GOLD_TYPE_OPTIONS.map((opt, idx) => ( + + ))} handleBlur('name', evt.target.value)} /> @@ -221,7 +221,7 @@ export default function FormContactsClient({ agreementUrl, policyUrl }: Props): type="email" name="email" autoComplete="on" - placeholder="Email" + placeholder="Email *" required onBlur={(evt) => handleBlur('email', evt.target.value)} /> @@ -238,8 +238,10 @@ export default function FormContactsClient({ agreementUrl, policyUrl }: Props): className={formStyles['form__input']} name="message" rows={5} - placeholder="Ваше сообщение" + placeholder="Сообщение *" autoComplete="off" + required + onBlur={(evt) => handleBlur('message', evt.target.value)} > diff --git a/src/components/Forms/FormOrder/FormOrder.tsx b/src/components/Forms/FormOrder/FormOrder.tsx index 56b1ed2..1b2c6a8 100644 --- a/src/components/Forms/FormOrder/FormOrder.tsx +++ b/src/components/Forms/FormOrder/FormOrder.tsx @@ -5,7 +5,7 @@ import type { CategoryFromServer, PriceItem } from '@/types/types' export default async function FormOrder(): Promise { const [categories, prices] = await Promise.all([ - fetchCollection('categories', { sort: { sort: 1 } }), + fetchCollection('category', { sort: { sort: 1 } }), fetchCollection('price', { sort: { sort: 1 } }), ]) diff --git a/src/components/Forms/FormOrder/FormOrderClient.tsx b/src/components/Forms/FormOrder/FormOrderClient.tsx index 28f3faa..0fc4661 100644 --- a/src/components/Forms/FormOrder/FormOrderClient.tsx +++ b/src/components/Forms/FormOrder/FormOrderClient.tsx @@ -17,7 +17,8 @@ import { z } from 'zod' import formStyles from '../../../styles/modules/form.module.scss' import { submitApplication } from '@/actions/forms' import { applicationFormSchema, validateFormField } from '@/lib/schemas' -import type { CategoryFromServer, PriceItem } from '@/types/types' +import type { CategoryFromServer, PriceItem, GoldTypeValue } from '@/types/types' +import { GOLD_TYPE_OPTIONS } from '@/const/const' import DropZone, { DropZoneRef } from '@/components/DropZone/DropZone' type Props = { @@ -54,7 +55,8 @@ export default function FormOrderClient({ const sizeOptionsRef = useRef>([]) // Радио-переключатель типа золочения - const [goldType, setGoldType] = useState<'without_gold' | 'all' | 'halo'>('without_gold') + const [goldType, setGoldType] = useState('without_gold') + const goldTypeLabel = GOLD_TYPE_OPTIONS.find((o) => o.value === goldType)?.label ?? '' const radioSwitchRef = useRef(null) const radioLabelRefs = useRef>([]) const radioIndicatorRef = useRef(null) @@ -405,7 +407,7 @@ export default function FormOrderClient({ <> - +

Вид иконы

@@ -540,13 +542,7 @@ export default function FormOrderClient({ role="radiogroup" aria-label="Тип золочения" > - {( - [ - { value: 'without_gold', label: 'Без золота' }, - { value: 'all', label: 'Золотой фон и нимб' }, - { value: 'halo', label: 'Только нимб' }, - ] as const - ).map((opt, idx) => ( + {GOLD_TYPE_OPTIONS.map((opt, idx) => (
- {slidesList.length > 0 && ( -
- -
- )} - - {slidesList.length === 0 && imageSrc && ( -
- {alt} -
- )} + ) diff --git a/src/components/Detail/Detail.module.scss b/src/components/Detail/Detail.module.scss index 2639b03..bedb7ef 100644 --- a/src/components/Detail/Detail.module.scss +++ b/src/components/Detail/Detail.module.scss @@ -1,8 +1,5 @@ @use '../../styles/variables.scss' as *; -.detail { -} - .detail__container { display: grid; grid-template-columns: 1fr 1fr; @@ -25,23 +22,3 @@ .detail__text { font-size: 22px; } - -.detail__slider { - overflow: hidden; -} - -.detail__image-wrapper { - position: relative; - - width: 100%; - height: 500px; - - @media (max-width: $tablet-min-width) { - height: 400px; - } -} - -.detail__image { - border-radius: 40px; - box-shadow: $cardShadow; -} diff --git a/src/components/Detail/Detail.tsx b/src/components/Detail/Detail.tsx index 8fa4e7c..38b1df0 100644 --- a/src/components/Detail/Detail.tsx +++ b/src/components/Detail/Detail.tsx @@ -2,10 +2,9 @@ import { JSX } from 'react' import clsx from 'clsx' import { ImageItem, SlideItem } from '@/types/types' import { createSanitizedHTML } from '@/functions/functions' -import SliderDetail from '@/components/SliderDetail/SliderDetail' import detailStyles from './Detail.module.scss' import { getImageUrl } from '@/lib/api-client' -import Image from 'next/image' +import GalleryBlock from '@/components/GalleryBlock/GalleryBlock' type DetailProps = { title: string @@ -21,6 +20,7 @@ export default function Detail({ description, }: DetailProps): JSX.Element { const src = getImageUrl(image._id, 800, 500) + const fullSrc = getImageUrl(image._id, 1600, 1000) const alt = image.alt ?? title return ( @@ -35,27 +35,12 @@ export default function Detail({ /> - {slidesList.length > 0 && ( -
- -
- )} - - {slidesList.length === 0 && image && ( -
- {alt} -
- )} + ) diff --git a/src/components/GalleryBlock/GalleryBlock.module.scss b/src/components/GalleryBlock/GalleryBlock.module.scss new file mode 100644 index 0000000..321a776 --- /dev/null +++ b/src/components/GalleryBlock/GalleryBlock.module.scss @@ -0,0 +1,84 @@ +@use '../../styles/variables.scss' as *; + +.gallery-block__slider { + overflow: hidden; +} + +.gallery-block__slider-wrapper { + display: contents; +} + +.gallery-block__image-wrapper { + position: relative; + + width: 100%; + height: 500px; + + @media (max-width: $tablet-min-width) { + height: 400px; + } +} + +.gallery-block__image-btn { + position: relative; + + display: block; + overflow: hidden; + width: 100%; + height: 100%; + padding: 0; + + background: none; + border: none; + border-radius: 40px; + cursor: pointer; + + @media (hover: hover) { + &:hover { + .gallery-block__image { + transform: scale(1.05); + } + + .gallery-block__image-overlay { + opacity: 1; + } + } + } +} + +.gallery-block__image-overlay { + position: absolute; + inset: 0; + z-index: 1; + + display: flex; + justify-content: center; + align-items: center; + + opacity: 0; + background-color: rgba(0, 0, 0, 0.35); + border-radius: 40px; + + pointer-events: none; + + transition: opacity 0.3s ease-in-out; +} + +.gallery-block__image-overlay-icon { + width: 48px; + height: 48px; + + color: $base-bg; + + @media (max-width: $tablet-min-width) { + width: 36px; + height: 36px; + } +} + +.gallery-block__image { + box-shadow: $cardShadow; + border-radius: 40px; + + transition: transform 0.3s ease-in-out; +} diff --git a/src/components/GalleryBlock/GalleryBlock.tsx b/src/components/GalleryBlock/GalleryBlock.tsx new file mode 100644 index 0000000..0525bd3 --- /dev/null +++ b/src/components/GalleryBlock/GalleryBlock.tsx @@ -0,0 +1,113 @@ +'use client' + +import { JSX, useEffect, useRef } from 'react' +import Image from 'next/image' +import lightGallery from 'lightgallery' +import lgThumbnail from 'lightgallery/plugins/thumbnail' +import lgZoom from 'lightgallery/plugins/zoom' +import 'lightgallery/css/lightgallery.css' +import 'lightgallery/css/lg-thumbnail.css' +import 'lightgallery/css/lg-zoom.css' +import type { LightGallery } from 'lightgallery/lightgallery' +import { SlideItem } from '@/types/types' +import GalleryBlockSlider from '@/components/GalleryBlockSlider/GalleryBlockSlider' +import galleryBlockStyles from './GalleryBlock.module.scss' + +type GalleryBlockProps = { + slidesList: SlideItem[] + imageSrc: string + imageFullSrc: string + imageAlt: string +} + +export default function GalleryBlock({ + slidesList, + imageSrc, + imageFullSrc, + imageAlt, +}: GalleryBlockProps): JSX.Element { + const containerRef = useRef(null) + const lgInstanceRef = useRef(null) + + const hasSlides = slidesList.length > 0 + + useEffect(() => { + if (!containerRef.current) return + + const dynamicEl = hasSlides + ? slidesList.map((item) => ({ src: item.image, thumb: item.image, alt: item.alt })) + : [{ src: imageFullSrc, thumb: imageSrc, alt: imageAlt }] + + lgInstanceRef.current = lightGallery(containerRef.current, { + dynamic: true, + dynamicEl, + plugins: [lgThumbnail, lgZoom], + speed: 400, + counter: dynamicEl.length > 1, + }) + + return () => { + lgInstanceRef.current?.destroy() + lgInstanceRef.current = null + } + }, [hasSlides, slidesList, imageSrc, imageFullSrc, imageAlt]) + + const handleSlideClick = (index: number) => { + lgInstanceRef.current?.openGallery(index) + } + + return ( +
+ {hasSlides ? ( +
+ +
+ ) : ( + imageSrc && ( +
+ +
+ ) + )} +
+ ) +} diff --git a/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss b/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss new file mode 100644 index 0000000..07adcff --- /dev/null +++ b/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss @@ -0,0 +1,116 @@ +@use '../../styles/variables.scss' as *; + +.slider-detail__item { + height: 500px; + + @media (max-width: $tablet-min-width) { + height: 400px; + } +} + +.slider-detail__item-btn { + position: relative; + + display: block; + overflow: hidden; + width: 100%; + height: 100%; + padding: 0; + + background: none; + border: none; + border-radius: 40px; + + cursor: pointer; + + @media (hover: hover) { + &:hover { + .slider-detail__item-image { + transform: scale(1.05); + } + + .slider-detail__item-overlay { + opacity: 1; + } + } + } +} + +.slider-detail__item-overlay { + position: absolute; + inset: 0; + z-index: 1; + + display: flex; + justify-content: center; + align-items: center; + + opacity: 0; + background-color: rgba(0, 0, 0, 0.35); + border-radius: 40px; + + pointer-events: none; + + transition: opacity 0.3s ease-in-out; +} + +.slider-detail__item-overlay-icon { + width: 48px; + height: 48px; + + color: $base-bg; + + @media (max-width: $tablet-min-width) { + width: 36px; + height: 36px; + } +} + +.slider-detail__item-image { + border-radius: 40px; + transition: transform 0.3s ease-in-out; +} + +.slider-detail__pagination { + position: absolute; + top: unset; + right: 20px; + bottom: 20px; + left: unset; + z-index: 2; + + opacity: 0.8; +} + +.slider-detail__navigation-item { + position: absolute; + top: 50%; + bottom: unset; + z-index: 2; + + width: 70px; + height: 70px; + + transform: translateY(-50%); + + @media (max-width: $tablet-min-width) { + width: 60px; + height: 60px; + } + + &--prev { + left: 20px; + + @media (max-width: $tablet-min-width) { + left: 10px; + } + } + + &--next { + right: 20px; + + @media (max-width: $tablet-min-width) { + right: 10px; + } + } +} diff --git a/src/components/SliderDetail/SliderDetail.tsx b/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx similarity index 52% rename from src/components/SliderDetail/SliderDetail.tsx rename to src/components/GalleryBlockSlider/GalleryBlockSlider.tsx index 5d26c00..da14ccc 100644 --- a/src/components/SliderDetail/SliderDetail.tsx +++ b/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx @@ -15,19 +15,23 @@ import { SlideItem } from '@/types/types' import Image from 'next/image' import sliderStyles from '../../styles/modules/slider.module.scss' -import sliderDetailStyles from './SliderDetail.module.scss' +import galleryBlockSliderStyles from './GalleryBlockSlider.module.scss' -type SliderDetailProps = { +type GalleryBlockSliderProps = { items: SlideItem[] + onSlideClick?: (index: number) => void } -export default function SliderDetail({ items }: SliderDetailProps): JSX.Element { +export default function GalleryBlockSlider({ + items, + onSlideClick, +}: GalleryBlockSliderProps): JSX.Element { const [prevEl, setPrevEl] = useState(null) const [nextEl, setNextEl] = useState(null) const [paginationEl, setPaginationEl] = useState(null) return ( -
+
- {items.map((item) => { + {items.map((item, index) => { return ( - {item.alt} + {onSlideClick ? ( + + ) : ( + {item.alt} + )} ) })} @@ -77,7 +115,7 @@ export default function SliderDetail({ items }: SliderDetailProps): JSX.Element ref={setPaginationEl} className={clsx( sliderStyles['slider__pagination'], - sliderDetailStyles['slider-detail__pagination'], + galleryBlockSliderStyles['slider-detail__pagination'], )} >
@@ -85,8 +123,8 @@ export default function SliderDetail({ items }: SliderDetailProps): JSX.Element ref={setPrevEl} className={clsx( sliderStyles['slider__navigation-item'], - sliderDetailStyles['slider-detail__navigation-item'], - sliderDetailStyles['slider-detail__navigation-item--prev'], + galleryBlockSliderStyles['slider-detail__navigation-item'], + galleryBlockSliderStyles['slider-detail__navigation-item--prev'], )} aria-label="Предыдущий слайд" > @@ -102,8 +140,8 @@ export default function SliderDetail({ items }: SliderDetailProps): JSX.Element ref={setNextEl} className={clsx( sliderStyles['slider__navigation-item'], - sliderDetailStyles['slider-detail__navigation-item'], - sliderDetailStyles['slider-detail__navigation-item--next'], + galleryBlockSliderStyles['slider-detail__navigation-item'], + galleryBlockSliderStyles['slider-detail__navigation-item--next'], )} aria-label="Следующий слайд" > diff --git a/src/components/ReviewsList/ReviewPhoto.tsx b/src/components/ReviewsList/ReviewPhoto.tsx index 0e7e5bb..c2b960a 100644 --- a/src/components/ReviewsList/ReviewPhoto.tsx +++ b/src/components/ReviewsList/ReviewPhoto.tsx @@ -3,9 +3,9 @@ import { JSX, useEffect, useRef } from 'react' import Image from 'next/image' import lightGallery from 'lightgallery' -import 'lightgallery/css/lightgallery.css' import lgThumbnail from 'lightgallery/plugins/thumbnail' import lgZoom from 'lightgallery/plugins/zoom' +import 'lightgallery/css/lightgallery.css' import 'lightgallery/css/lg-thumbnail.css' import 'lightgallery/css/lg-zoom.css' import type { LightGallery } from 'lightgallery/lightgallery' diff --git a/src/components/SliderDetail/SliderDetail.module.scss b/src/components/SliderDetail/SliderDetail.module.scss deleted file mode 100644 index 6af9293..0000000 --- a/src/components/SliderDetail/SliderDetail.module.scss +++ /dev/null @@ -1,60 +0,0 @@ -@use '../../styles/variables.scss' as *; - -.slider-detail { -} - -.slider-detail__item { - height: 500px; - - @media (max-width: $tablet-min-width) { - height: 400px; - } -} - -.slider-detail__item-image { - border-radius: 40px; -} - -.slider-detail__pagination { - position: absolute; - top: unset; - right: 20px; - bottom: 20px; - left: unset; - z-index: 2; - - opacity: 0.8; -} - -.slider-detail__navigation-item { - position: absolute; - top: 50%; - bottom: unset; - z-index: 2; - - width: 70px; - height: 70px; - - transform: translateY(-50%); - - @media (max-width: $tablet-min-width) { - width: 60px; - height: 60px; - } - - &--prev { - left: 20px; - - @media (max-width: $tablet-min-width) { - left: 10px; - } - } - - &--next { - right: 20px; - - @media (max-width: $tablet-min-width) { - right: 10px; - } - } -} diff --git a/src/lib/CockpitAPI.ts b/src/lib/CockpitAPI.ts index 343a938..0307e01 100644 --- a/src/lib/CockpitAPI.ts +++ b/src/lib/CockpitAPI.ts @@ -105,7 +105,8 @@ class CockpitClient { formData.append('file', file, file.name) try { - const url = `${this.baseUrl.replace(/\/$/, '')}/api/upload` + (folder ? `?folder=${folder}` : '') + const url = + `${this.baseUrl.replace(/\/$/, '')}/api/upload` + (folder ? `?folder=${folder}` : '') const response = await fetch(url, { method: 'POST', From 6fde264c143742cb768f02db5238581b0bdb3727 Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Wed, 13 May 2026 15:18:51 +0300 Subject: [PATCH 5/6] Fix images optimization --- next.config.ts | 1 + src/app/categories/[categories-detail]/page.tsx | 9 +++++++-- src/app/categories/page.tsx | 2 +- src/app/in-stock/[in-stock-detail]/page.tsx | 10 +++++++--- src/app/in-stock/page.tsx | 2 +- src/app/news/[news-detail]/page.tsx | 8 +++++--- src/app/news/page.tsx | 2 +- src/app/reviews/page.tsx | 4 ++-- src/app/works/[works-detail]/page.tsx | 10 +++++++--- src/app/works/page.tsx | 2 +- src/components/AboutPage/AboutPage.tsx | 6 ++++-- src/components/Card/Card.tsx | 4 ++-- src/components/Detail/Detail.tsx | 2 +- src/components/GalleryBlock/GalleryBlock.module.scss | 1 + .../GalleryBlockSlider.module.scss | 1 + .../GalleryBlockSlider/GalleryBlockSlider.tsx | 2 ++ src/components/GalleryPage/GalleryPageClient.tsx | 5 +++-- src/components/Main/Masters/Masters.tsx | 2 +- src/components/Main/News/News.tsx | 2 +- src/components/Main/Process/Process.module.scss | 1 + src/components/Main/Process/Process.tsx | 6 +++--- src/components/Main/Reviews/Reviews.tsx | 4 ++-- .../Main/SliderMain/SliderMain.module.scss | 1 + src/components/Main/SliderMain/SliderMain.tsx | 2 +- src/components/Main/SliderMain/SliderMainClient.tsx | 5 ++++- src/functions/gallery.ts | 6 +++--- src/lib/CockpitAPI.ts | 11 +++++++++-- src/lib/api-client.ts | 12 +++++++++--- src/types/types.ts | 9 +++++++++ 29 files changed, 91 insertions(+), 41 deletions(-) diff --git a/next.config.ts b/next.config.ts index 7a05897..f555911 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const cockpitHost = cockpitUrl ? new URL(cockpitUrl).hostname : '' const nextConfig: NextConfig = { images: { formats: ['image/avif', 'image/webp'], + qualities: [80, 90], remotePatterns: [ { protocol: 'https', diff --git a/src/app/categories/[categories-detail]/page.tsx b/src/app/categories/[categories-detail]/page.tsx index 5ba5e5d..490dac5 100644 --- a/src/app/categories/[categories-detail]/page.tsx +++ b/src/app/categories/[categories-detail]/page.tsx @@ -38,7 +38,12 @@ export async function generateMetadata({ params }: PageProps): Promise ? category.description.replace(/<[^>]*>/g, '').slice(0, 160) : '', images: category.image - ? [{ url: getImageUrl(category.image._id, 1200, 630), alt: category.title }] + ? [ + { + url: getImageUrl(category.image._id, 1200, 630, { mime: 'jpeg' }), + alt: category.title, + }, + ] : [], }, alternates: { @@ -78,7 +83,7 @@ export default async function Page({ params }: PageProps): Promise const slidesList: SlideItem[] = category.slider?.map((image) => ({ id: image._id, - image: getImageUrl(image._id, 800, 800), + image: getImageUrl(image._id, 800, 500), alt: image.title || category.title, })) diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx index cf1568e..e70d188 100644 --- a/src/app/categories/page.tsx +++ b/src/app/categories/page.tsx @@ -53,7 +53,7 @@ export default async function Page({ title: category.title, description: category.description, href: `/categories/${category.slug || category._id}`, - image: getImageUrl(category.image._id, 400, 400), + image: getImageUrl(category.image._id, 600, 500), alt: category.image.title || category.title, })) diff --git a/src/app/in-stock/[in-stock-detail]/page.tsx b/src/app/in-stock/[in-stock-detail]/page.tsx index 386e454..a2f4e7b 100644 --- a/src/app/in-stock/[in-stock-detail]/page.tsx +++ b/src/app/in-stock/[in-stock-detail]/page.tsx @@ -33,7 +33,9 @@ export async function generateMetadata({ params }: PageProps): Promise openGraph: { title: work.title, description, - images: work.image ? [{ url: getImageUrl(work.image._id, 1200, 630), alt: work.title }] : [], + images: work.image + ? [{ url: getImageUrl(work.image._id, 1200, 630, { mime: 'jpeg' }), alt: work.title }] + : [], }, alternates: { canonical: `${process.env.SITE_URL || process.env.NEXT_PUBLIC_SITE_URL}/in-stock/${work.slug || work._id}`, @@ -72,7 +74,7 @@ export default async function Page({ params }: PageProps): Promise const slidesList: SlideItem[] = work.slider?.map((image) => ({ id: image._id, - image: getImageUrl(image._id, 800, 800), + image: getImageUrl(image._id, 800, 500), alt: image.title || work.title, })) || [] @@ -87,7 +89,9 @@ export default async function Page({ params }: PageProps): Promise description: work.description ? work.description.replace(/<[^>]*>/g, '').slice(0, 160) : work.title, - image: work.image ? getImageUrl(work.image._id, 1200, 630) : undefined, + image: work.image + ? getImageUrl(work.image._id, 1200, 630, { mode: 'thumbnail', mime: 'jpeg' }) + : undefined, brand: { '@type': 'Organization', name: 'Иконописная Артель', diff --git a/src/app/in-stock/page.tsx b/src/app/in-stock/page.tsx index 0f4e208..f1d0c8a 100644 --- a/src/app/in-stock/page.tsx +++ b/src/app/in-stock/page.tsx @@ -58,7 +58,7 @@ export default async function Page({ title: work.title, description: work.description, href: `/in-stock/${work.slug || work._id}`, - image: getImageUrl(work.image._id, 400, 400), + image: getImageUrl(work.image._id, 600, 500), alt: work.image.title || work.title, })) diff --git a/src/app/news/[news-detail]/page.tsx b/src/app/news/[news-detail]/page.tsx index 6f9774f..4abb9b8 100644 --- a/src/app/news/[news-detail]/page.tsx +++ b/src/app/news/[news-detail]/page.tsx @@ -32,7 +32,9 @@ export async function generateMetadata({ params }: PageParams): Promise const slidesList: SlideItem[] = news.slider?.map((image) => ({ id: image._id, - image: getImageUrl(image._id, 800, 800), + image: getImageUrl(image._id, 800, 500), alt: image.title || news.title, })) || [] @@ -81,7 +83,7 @@ export default async function Page({ params }: PageParams): Promise '@type': 'Article', headline: news.title, description: (news.content || news.description || '').replace(/<[^>]*>/g, '').slice(0, 160), - image: news.image ? getImageUrl(news.image._id, 1200, 630) : undefined, + image: news.image ? getImageUrl(news.image._id, 1200, 630, { mime: 'jpeg' }) : undefined, datePublished: news._created ? new Date(news._created * 1000).toISOString() : undefined, dateModified: news._modified ? new Date(news._modified * 1000).toISOString() : undefined, author: { diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index abcdcb3..7f45377 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -55,7 +55,7 @@ export default async function Page({ title: news.title, description: news.description, href: `/news/${news.slug || news._id}`, - image: getImageUrl(news.image._id, 400, 400), + image: getImageUrl(news.image._id, 600, 500), alt: news.image.title || news.title, })) diff --git a/src/app/reviews/page.tsx b/src/app/reviews/page.tsx index d3a9bc9..42b9736 100644 --- a/src/app/reviews/page.tsx +++ b/src/app/reviews/page.tsx @@ -58,8 +58,8 @@ export default async function Page({ review.photos.forEach((img) => { if (img._id) { photos.push({ - thumb: getImageUrl(img._id, 400, 300, 'thumbnail'), - full: getImageUrl(img._id, 1920, 1080, 'bestFit'), + thumb: getImageUrl(img._id, 400, 300), + full: getImageUrl(img._id, 1920, 1080, { mode: 'bestFit' }), }) } }) diff --git a/src/app/works/[works-detail]/page.tsx b/src/app/works/[works-detail]/page.tsx index 9828aa2..7dc9397 100644 --- a/src/app/works/[works-detail]/page.tsx +++ b/src/app/works/[works-detail]/page.tsx @@ -33,7 +33,9 @@ export async function generateMetadata({ params }: PageProps): Promise openGraph: { title: work.title, description, - images: work.image ? [{ url: getImageUrl(work.image._id, 1200, 630), alt: work.title }] : [], + images: work.image + ? [{ url: getImageUrl(work.image._id, 1200, 630, { mime: 'jpeg' }), alt: work.title }] + : [], }, alternates: { canonical: `${process.env.SITE_URL || process.env.NEXT_PUBLIC_SITE_URL}/works/${work.slug || work._id}`, @@ -72,7 +74,7 @@ export default async function Page({ params }: PageProps): Promise const slidesList: SlideItem[] = work.slider?.map((image) => ({ id: image._id, - image: getImageUrl(image._id, 800, 800), + image: getImageUrl(image._id, 800, 500), alt: image.title || work.title, })) || [] @@ -87,7 +89,9 @@ export default async function Page({ params }: PageProps): Promise description: work.description ? work.description.replace(/<[^>]*>/g, '').slice(0, 160) : work.title, - image: work.image ? getImageUrl(work.image._id, 1200, 630) : undefined, + image: work.image + ? getImageUrl(work.image._id, 1200, 630, { mode: 'thumbnail', mime: 'jpeg' }) + : undefined, brand: { '@type': 'Organization', name: 'Иконописная Артель', diff --git a/src/app/works/page.tsx b/src/app/works/page.tsx index b8d8b7d..d2dfc53 100644 --- a/src/app/works/page.tsx +++ b/src/app/works/page.tsx @@ -55,7 +55,7 @@ export default async function Page({ title: work.title, description: work.description, href: `/works/${work.slug || work._id}`, - image: getImageUrl(work.image._id, 400, 400), + image: getImageUrl(work.image._id, 600, 500), alt: work.image.title || work.title, })) diff --git a/src/components/AboutPage/AboutPage.tsx b/src/components/AboutPage/AboutPage.tsx index b150c82..309d543 100644 --- a/src/components/AboutPage/AboutPage.tsx +++ b/src/components/AboutPage/AboutPage.tsx @@ -24,12 +24,14 @@ export default async function AboutPage(): Promise { const slidesList: SlideItem[] = about.slider?.map((image) => ({ id: image._id, - image: getImageUrl(image._id, 800, 800), + image: getImageUrl(image._id, 800, 500), alt: image.title || about.title, })) const imageSrc = about.image ? getImageUrl(about.image._id, 800, 500) : '' - const imageFullSrc = about.image ? getImageUrl(about.image._id, 1600, 1000) : '' + const imageFullSrc = about.image + ? getImageUrl(about.image._id, 1600, 1000, { mode: 'bestFit' }) + : '' const alt = about.image?.alt ?? title return ( diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index ad483f5..9b06b2c 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -17,8 +17,8 @@ export default function Card({ data }: CardProps): JSX.Element { className={cardStyles['card__image']} src={data.image} alt={data.alt} - width={400} - height={400} + width={600} + height={500} />
diff --git a/src/components/Detail/Detail.tsx b/src/components/Detail/Detail.tsx index 38b1df0..6ed6e11 100644 --- a/src/components/Detail/Detail.tsx +++ b/src/components/Detail/Detail.tsx @@ -20,7 +20,7 @@ export default function Detail({ description, }: DetailProps): JSX.Element { const src = getImageUrl(image._id, 800, 500) - const fullSrc = getImageUrl(image._id, 1600, 1000) + const fullSrc = getImageUrl(image._id, 1600, 1000, { mode: 'bestFit' }) const alt = image.alt ?? title return ( diff --git a/src/components/GalleryBlock/GalleryBlock.module.scss b/src/components/GalleryBlock/GalleryBlock.module.scss index 321a776..1ae683f 100644 --- a/src/components/GalleryBlock/GalleryBlock.module.scss +++ b/src/components/GalleryBlock/GalleryBlock.module.scss @@ -77,6 +77,7 @@ } .gallery-block__image { + object-fit: cover; box-shadow: $cardShadow; border-radius: 40px; diff --git a/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss b/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss index 07adcff..fa56dbe 100644 --- a/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss +++ b/src/components/GalleryBlockSlider/GalleryBlockSlider.module.scss @@ -67,6 +67,7 @@ } .slider-detail__item-image { + object-fit: cover; border-radius: 40px; transition: transform 0.3s ease-in-out; } diff --git a/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx b/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx index da14ccc..569e0e4 100644 --- a/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx +++ b/src/components/GalleryBlockSlider/GalleryBlockSlider.tsx @@ -79,6 +79,7 @@ export default function GalleryBlockSlider({ src={item.image} alt={item.alt} fill + sizes="(max-width: 768px) 100vw, 50vw" /> )} diff --git a/src/components/GalleryPage/GalleryPageClient.tsx b/src/components/GalleryPage/GalleryPageClient.tsx index c5a5940..cc68345 100644 --- a/src/components/GalleryPage/GalleryPageClient.tsx +++ b/src/components/GalleryPage/GalleryPageClient.tsx @@ -126,8 +126,9 @@ export default function GalleryPageClient({ items }: GalleryPageClientProps): JS className={galleryPageStyles['gallery__image']} src={item.imageUrl} alt={item.imageAlt} - width={400} - height={400} + width={600} + height={450} + sizes="(max-width: 600px) 100vw, (max-width: 900px) 50vw, 25vw" />
diff --git a/src/components/Main/Masters/Masters.tsx b/src/components/Main/Masters/Masters.tsx index 248d5f9..ca96128 100644 --- a/src/components/Main/Masters/Masters.tsx +++ b/src/components/Main/Masters/Masters.tsx @@ -21,7 +21,7 @@ export default async function Masters(): Promise { title: master.name, description: master.description, href: `/masters/${master.slug || master._id}`, - image: getImageUrl(master.image._id, 400, 400), + image: getImageUrl(master.image._id, 600, 500), alt: master.image.title || master.name, })) diff --git a/src/components/Main/News/News.tsx b/src/components/Main/News/News.tsx index 2c00ff5..92dad46 100644 --- a/src/components/Main/News/News.tsx +++ b/src/components/Main/News/News.tsx @@ -21,7 +21,7 @@ export default async function News(): Promise { title: news.title, description: news.description, href: `/news/${news.slug || news._id}`, - image: getImageUrl(news.image._id, 400, 400), + image: getImageUrl(news.image._id, 600, 500), alt: news.image.title || news.title, })) diff --git a/src/components/Main/Process/Process.module.scss b/src/components/Main/Process/Process.module.scss index 22257e9..220ecaf 100644 --- a/src/components/Main/Process/Process.module.scss +++ b/src/components/Main/Process/Process.module.scss @@ -74,6 +74,7 @@ height: auto; max-height: 400px; + object-fit: cover; box-shadow: $cardShadow; border-radius: 40px; aspect-ratio: 4 / 3; diff --git a/src/components/Main/Process/Process.tsx b/src/components/Main/Process/Process.tsx index 6b7511b..c499198 100644 --- a/src/components/Main/Process/Process.tsx +++ b/src/components/Main/Process/Process.tsx @@ -29,7 +29,7 @@ export default async function Process(): Promise { {processList.map((process, index) => { const title = process.title const description = process.description - const image = getImageUrl(process.image._id, 800, 500) + const image = getImageUrl(process.image._id, 800, 600) const alt = process.alt ?? process.title return ( @@ -42,8 +42,8 @@ export default async function Process(): Promise { className={processStyles['process__item-image']} src={image} alt={alt} - width={500} - height={500} + width={800} + height={600} />

{title}

diff --git a/src/components/Main/Reviews/Reviews.tsx b/src/components/Main/Reviews/Reviews.tsx index 8a0745e..1c968c3 100644 --- a/src/components/Main/Reviews/Reviews.tsx +++ b/src/components/Main/Reviews/Reviews.tsx @@ -22,8 +22,8 @@ export default async function Reviews(): Promise { review.photos.forEach((img) => { if (img._id) { photos.push({ - thumb: getImageUrl(img._id, 400, 300, 'thumbnail'), - full: getImageUrl(img._id, 1920, 1080, 'bestFit'), + thumb: getImageUrl(img._id, 400, 300), + full: getImageUrl(img._id, 1920, 1080, { mode: 'bestFit' }), }) } }) diff --git a/src/components/Main/SliderMain/SliderMain.module.scss b/src/components/Main/SliderMain/SliderMain.module.scss index 9cd9d0f..56885ac 100644 --- a/src/components/Main/SliderMain/SliderMain.module.scss +++ b/src/components/Main/SliderMain/SliderMain.module.scss @@ -78,6 +78,7 @@ } .slider-main__item-image { + object-fit: cover; filter: brightness(0.75); } diff --git a/src/components/Main/SliderMain/SliderMain.tsx b/src/components/Main/SliderMain/SliderMain.tsx index ec5b199..90e33f2 100644 --- a/src/components/Main/SliderMain/SliderMain.tsx +++ b/src/components/Main/SliderMain/SliderMain.tsx @@ -17,7 +17,7 @@ export default async function SliderMain(): Promise { const slidesList: CardItem[] = mainSliderData.map((item) => ({ id: item._id, - image: getImageUrl(item.image._id, 1200, 800), + image: getImageUrl(item.image._id, 1920, 1080, { mode: 'bestFit' }), alt: item.title || '', title: item.title || '', description: item.description || '', diff --git a/src/components/Main/SliderMain/SliderMainClient.tsx b/src/components/Main/SliderMain/SliderMainClient.tsx index ae78a71..3c8dbbf 100644 --- a/src/components/Main/SliderMain/SliderMainClient.tsx +++ b/src/components/Main/SliderMain/SliderMainClient.tsx @@ -57,7 +57,7 @@ export default function SliderMainClient({ slidesList }: SliderMainClientProps): } as PaginationOptions } > - {slidesList.map((slide) => { + {slidesList.map((slide, index) => { return (
diff --git a/src/functions/gallery.ts b/src/functions/gallery.ts index db69dd1..6cf1040 100644 --- a/src/functions/gallery.ts +++ b/src/functions/gallery.ts @@ -23,9 +23,9 @@ export function prepareGalleryItems( slug: item.slug, fullPath, type: item.type, - imageUrl: getImageUrl(item.image._id, 400, 400), - imageLargeUrl: getImageUrl(item.image._id, 1920, 1920), - imageThumbUrl: getImageUrl(item.image._id, 400, 400), + imageUrl: getImageUrl(item.image._id, 600, 450), + imageLargeUrl: getImageUrl(item.image._id, 1920, 1080, { mode: 'bestFit' }), + imageThumbUrl: getImageUrl(item.image._id, 600, 450), imageAlt: item.image.title || item.title, hasNestedCategories, childrenCount, diff --git a/src/lib/CockpitAPI.ts b/src/lib/CockpitAPI.ts index 0307e01..355402d 100644 --- a/src/lib/CockpitAPI.ts +++ b/src/lib/CockpitAPI.ts @@ -144,8 +144,15 @@ class CockpitClient { return assets } - getImageUrl(imageId: string, width: number, height: number) { - return `${this.baseUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=80&o=1` + getImageUrl( + imageId: string, + width: number, + height: number, + mode: 'thumbnail' | 'bestFit' | 'resize' | 'fitToWidth' | 'fitToHeight' = 'bestFit', + mime: 'auto' | 'gif' | 'jpeg' | 'png' | 'webp' | 'bmp' = 'webp', + quality: number = 90, + ) { + return `${this.baseUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=${quality}&o=1&mime=${mime}&m=${mode}` } private createQueryString(options: CockpitOptions = {}) { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index da6715a..276fb37 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,3 +1,5 @@ +import type { ImageOptions } from '@/types/types' + /** * API Client */ @@ -245,16 +247,20 @@ export async function fetchCollectionCount( * @param imageId - ID изображения * @param width - ширина * @param height - высота - * @param mode - режим ресайза + * @param options - опции ресайза + * @param options.mode - режим ресайза (по умолчанию 'thumbnail') + * @param options.mime - формат изображения (по умолчанию 'webp') + * @param options.quality - качество от 1 до 100 (по умолчанию 90) */ export function getImageUrl( imageId: string, width: number, height: number, - mode?: string | null, + options: ImageOptions = {}, ): string { + const { mode = 'thumbnail', mime = 'webp', quality = 90 } = options const cockpitUrl = process.env.COCKPIT_API_URL || '' - return `${cockpitUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=80&o=1${mode ? `&m=${mode}` : ''}` + return `${cockpitUrl}api/assets/image/${imageId}?w=${width}&h=${height}&q=${quality}&o=1&mime=${mime}&m=${mode}` } /** diff --git a/src/types/types.ts b/src/types/types.ts index 67d03de..fec74cc 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -212,3 +212,12 @@ export type FormState = { message: string errors?: Record } + +export type ImageMode = 'thumbnail' | 'bestFit' | 'resize' | 'fitToWidth' | 'fitToHeight' +export type ImageMime = 'auto' | 'gif' | 'jpeg' | 'png' | 'webp' | 'bmp' + +export interface ImageOptions { + mode?: ImageMode + mime?: ImageMime + quality?: number +} From 254c0204bbd7398a20118c14053053cede7c13a3 Mon Sep 17 00:00:00 2001 From: Yuriy Plotnikov Date: Wed, 13 May 2026 20:24:25 +0300 Subject: [PATCH 6/6] Fix images optimization --- next.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/next.config.ts b/next.config.ts index f555911..bd313d5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,7 @@ const nextConfig: NextConfig = { images: { formats: ['image/avif', 'image/webp'], qualities: [80, 90], + minimumCacheTTL: 43200, remotePatterns: [ { protocol: 'https',