deploy
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type { Category, Product } from '@/entities/product/model/types'
|
import type { Category, Product } from '@/entities/product/model/types'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { apiBaseURL } from '@/shared/config'
|
import { apiBaseURL } from '@/shared/config'
|
||||||
|
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
|
|
||||||
export type PublicProductsResponse = {
|
export type PublicProductsResponse = {
|
||||||
items: Product[]
|
items: Product[]
|
||||||
@@ -119,8 +120,16 @@ export async function deleteAdminCategory(id: string): Promise<void> {
|
|||||||
|
|
||||||
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||||
|
const list = Array.from(files)
|
||||||
|
for (const f of list) {
|
||||||
|
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
for (const f of Array.from(files)) {
|
for (const f of list) {
|
||||||
fd.append('files', f, f.name)
|
fd.append('files', f, f.name)
|
||||||
}
|
}
|
||||||
const token = localStorage.getItem('craftshop_auth_token')
|
const token = localStorage.getItem('craftshop_auth_token')
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
||||||
import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
||||||
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
||||||
|
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { GallerySliderSection } from './GallerySliderSection'
|
import { GallerySliderSection } from './GallerySliderSection'
|
||||||
|
|
||||||
@@ -80,6 +81,10 @@ export function AdminGalleryPage() {
|
|||||||
|
|
||||||
<Divider sx={{ mb: 3 }} />
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Форматы: PNG, JPEG, WebP. На один файл — до {formatAdminImageMaxSizeHint()}.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Button variant="contained" component="label" disabled={uploadMut.isPending}>
|
<Button variant="contained" component="label" disabled={uploadMut.isPending}>
|
||||||
Загрузить файлы
|
Загрузить файлы
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
uploadAdminProductImages,
|
uploadAdminProductImages,
|
||||||
} from '@/entities/product/api/product-api'
|
} from '@/entities/product/api/product-api'
|
||||||
import type { Category, Product } from '@/entities/product/model/types'
|
import type { Category, Product } from '@/entities/product/model/types'
|
||||||
|
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
@@ -535,7 +536,8 @@ export function AdminPage() {
|
|||||||
Фото (загрузка)
|
Фото (загрузка)
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||||
Крестик на превью убирает фото только из карточки; файл остаётся на сервере и в галерее.
|
PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из
|
||||||
|
карточки; файл остаётся на сервере и в галерее.
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Должно совпадать с `getProductImageMaxFileBytes()` на сервере (по умолчанию 20 МБ). */
|
||||||
|
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
export function formatAdminImageMaxSizeHint(): string {
|
||||||
|
return `${Math.round(ADMIN_UPLOAD_IMAGE_MAX_BYTES / (1024 * 1024))} МБ`
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
Актуально для загрузок файлов (если нужны нестандартные лимиты):
|
Актуально для загрузок файлов (если нужны нестандартные лимиты):
|
||||||
|
|
||||||
- `PRODUCT_IMAGE_MAX_FILE_BYTES` — фото товаров в админке (по умолчанию 20 МБ).
|
- `ADMIN_IMAGE_MAX_FILE_BYTES` (или устаревшее `PRODUCT_IMAGE_MAX_FILE_BYTES`) — одно изображение в админке: товары, галерея (по умолчанию 20 МБ).
|
||||||
- `OTHER_UPLOAD_MAX_FILE_BYTES` — отзывы, чек оплаты и т.п. (по умолчанию 2 МБ).
|
- `OTHER_UPLOAD_MAX_FILE_BYTES` — отзывы, чек оплаты и т.п. (по умолчанию 2 МБ).
|
||||||
- `MAX_UPLOAD_BODY_BYTES` — весь POST multipart (по умолчанию рассчитывается от лимита фото товара × 10 + запас).
|
- `MAX_UPLOAD_BODY_BYTES` — весь POST multipart (по умолчанию рассчитывается от лимита фото товара × 10 + запас).
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -3,8 +3,10 @@ PORT=3333
|
|||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
JWT_SECRET=замените-на-секрет-jwt
|
JWT_SECRET=замените-на-секрет-jwt
|
||||||
|
|
||||||
# Загрузки (байты). Фото товара в админке: по умолчанию 20 МБ; отзывы/чек и т.п.: 2 МБ.
|
# Загрузки (байты). Админ: одно изображение (товары, галерея) — по умолчанию 20 МБ.
|
||||||
# PRODUCT_IMAGE_MAX_FILE_BYTES=20971520
|
# ADMIN_IMAGE_MAX_FILE_BYTES=20971520
|
||||||
|
# (устаревшее имя, то же значение) PRODUCT_IMAGE_MAX_FILE_BYTES=20971520
|
||||||
|
# Отзывы, чек оплаты и т.п.: 2 МБ.
|
||||||
# OTHER_UPLOAD_MAX_FILE_BYTES=2097152
|
# OTHER_UPLOAD_MAX_FILE_BYTES=2097152
|
||||||
# MAX_UPLOAD_BODY_BYTES=… — весь POST multipart при необходимости
|
# MAX_UPLOAD_BODY_BYTES=… — весь POST multipart при необходимости
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ await fastify.register(jwt, {
|
|||||||
await fastify.register(multipart, {
|
await fastify.register(multipart, {
|
||||||
limits: {
|
limits: {
|
||||||
files: 10,
|
files: 10,
|
||||||
|
/** Совпадает с лимитом одного файла для `POST /api/admin/uploads` (товары, галерея). */
|
||||||
fileSize: getProductImageMaxFileBytes(),
|
fileSize: getProductImageMaxFileBytes(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
const MB = 1024 * 1024
|
const MB = 1024 * 1024
|
||||||
|
|
||||||
/** Фото товаров в админке (на файл). По умолчанию 20 МБ. */
|
/**
|
||||||
export const PRODUCT_IMAGE_MAX_FILE_BYTES = 20 * MB
|
* Один файл изображения в админке: товары, галерея (`POST /api/admin/uploads`).
|
||||||
|
* Должно совпадать с лимитом плагина multipart в `server/src/index.js`.
|
||||||
|
*/
|
||||||
|
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB
|
||||||
|
|
||||||
|
/** @deprecated используйте ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT; оставлено для совместимости импортов */
|
||||||
|
export const PRODUCT_IMAGE_MAX_FILE_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||||
|
|
||||||
/** Отзывы, чек оплаты и прочие загрузки (на файл). По умолчанию 2 МБ. */
|
/** Отзывы, чек оплаты и прочие загрузки (на файл). По умолчанию 2 МБ. */
|
||||||
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * MB
|
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * MB
|
||||||
|
|
||||||
|
/** Лимит одного файла для админских изображений (байты). Env: `ADMIN_IMAGE_MAX_FILE_BYTES` или `PRODUCT_IMAGE_MAX_FILE_BYTES`. */
|
||||||
export function getProductImageMaxFileBytes() {
|
export function getProductImageMaxFileBytes() {
|
||||||
const n = Number(process.env.PRODUCT_IMAGE_MAX_FILE_BYTES)
|
const fromAdmin = Number(process.env.ADMIN_IMAGE_MAX_FILE_BYTES)
|
||||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : PRODUCT_IMAGE_MAX_FILE_BYTES
|
const fromLegacy = Number(process.env.PRODUCT_IMAGE_MAX_FILE_BYTES)
|
||||||
|
const n =
|
||||||
|
Number.isFinite(fromAdmin) && fromAdmin > 0
|
||||||
|
? fromAdmin
|
||||||
|
: Number.isFinite(fromLegacy) && fromLegacy > 0
|
||||||
|
? fromLegacy
|
||||||
|
: NaN
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOtherUploadMaxFileBytes() {
|
export function getOtherUploadMaxFileBytes() {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 MiB |
Reference in New Issue
Block a user