diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1657808..5cb3ec0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -37,6 +37,7 @@ services: volumes: - api-data:/app/App_Data - api-logs:/app/logs + - /opt/food-market-data/uploads:/app/uploads web: image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest} diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 96a2e03..28e306e 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -40,6 +40,13 @@ server { proxy_pass http://api:8080; } + # Статика изображений товаров — api раздаёт /uploads/... из volume. + location /uploads/ { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + # SPA fallback — all other routes return index.html location / { try_files $uri $uri/ /index.html; diff --git a/src/food-market.api/Controllers/Catalog/ProductImagesController.cs b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs new file mode 100644 index 0000000..4408fa2 --- /dev/null +++ b/src/food-market.api/Controllers/Catalog/ProductImagesController.cs @@ -0,0 +1,147 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Catalog; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Catalog; + +/// Локальное хранилище изображений товаров: multipart upload → +/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь +/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика. +[ApiController] +[Authorize] +[Route("api/catalog/products/{productId:guid}/images")] +public class ProductImagesController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IWebHostEnvironment _env; + + public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostEnvironment env) + { + _db = db; + _tenant = tenant; + _env = env; + } + + private static readonly HashSet AllowedExt = new(StringComparer.OrdinalIgnoreCase) + { ".jpg", ".jpeg", ".png", ".webp", ".gif" }; + + private const long MaxBytes = 10 * 1024 * 1024; + + private string UploadRoot => Path.Combine(_env.ContentRootPath, "uploads", "products"); + + public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder); + + [HttpGet] + public async Task>> List(Guid productId, CancellationToken ct) + { + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct); + if (product is null) return NotFound(); + + var images = await _db.ProductImages + .Where(i => i.ProductId == productId) + .OrderBy(i => i.SortOrder) + .Select(i => new ImageDto(i.Id, i.Url, i.IsMain, i.SortOrder)) + .ToListAsync(ct); + return images; + } + + [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] + [RequestSizeLimit(MaxBytes)] + public async Task> Upload(Guid productId, IFormFile file, CancellationToken ct) + { + if (file is null || file.Length == 0) return BadRequest(new { error = "No file." }); + if (file.Length > MaxBytes) return BadRequest(new { error = $"File too large (max {MaxBytes / 1024 / 1024} MB)." }); + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!AllowedExt.Contains(ext)) return BadRequest(new { error = "Only JPG/PNG/WEBP/GIF are allowed." }); + + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct); + if (product is null) return NotFound(); + + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var dir = Path.Combine(UploadRoot, productId.ToString()); + Directory.CreateDirectory(dir); + + var fileName = $"{Guid.NewGuid():N}{ext}"; + var fullPath = Path.Combine(dir, fileName); + using (var stream = System.IO.File.Create(fullPath)) + { + await file.CopyToAsync(stream, ct); + } + + var relativeUrl = $"/uploads/products/{productId}/{fileName}"; + var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct); + var isMain = sortOrder == 0; // первое загруженное — основное + var entity = new ProductImage + { + OrganizationId = orgId, + ProductId = productId, + Url = relativeUrl, + IsMain = isMain, + SortOrder = sortOrder, + }; + _db.ProductImages.Add(entity); + if (isMain) product.ImageUrl = relativeUrl; + await _db.SaveChangesAsync(ct); + + return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder); + } + + [HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task Delete(Guid productId, Guid imageId, CancellationToken ct) + { + var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); + if (image is null) return NotFound(); + + // Удаляем файл с диска (не фейлим если отсутствует). + var fileName = Path.GetFileName(image.Url); + var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName); + try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); } + catch { /* ignore */ } + + _db.ProductImages.Remove(image); + + // Если удалили основное — назначаем основным оставшуюся первую. + if (image.IsMain) + { + var next = await _db.ProductImages + .Where(i => i.ProductId == productId && i.Id != imageId) + .OrderBy(i => i.SortOrder) + .FirstOrDefaultAsync(ct); + if (next is not null) + { + next.IsMain = true; + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct); + if (product is not null) product.ImageUrl = next.Url; + } + else + { + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct); + if (product is not null) product.ImageUrl = null; + } + } + + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Manager,Storekeeper")] + public async Task SetMain(Guid productId, Guid imageId, CancellationToken ct) + { + var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct); + if (image is null) return NotFound(); + + await _db.ProductImages.Where(i => i.ProductId == productId).ExecuteUpdateAsync( + s => s.SetProperty(i => i.IsMain, false), ct); + image.IsMain = true; + + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct); + if (product is not null) product.ImageUrl = image.Url; + + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 3c4f757..5538013 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -150,6 +150,16 @@ app.UseAuthentication(); app.UseAuthorization(); + // Статика товарных изображений: физически /app/uploads (volume в compose), + // публичный URL /uploads/... — раздаются public, без auth. + var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads"); + System.IO.Directory.CreateDirectory(uploadsDir); + app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions + { + FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir), + RequestPath = "/uploads", + }); + if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/src/food-market.web/src/components/ProductImageGallery.tsx b/src/food-market.web/src/components/ProductImageGallery.tsx new file mode 100644 index 0000000..476263c --- /dev/null +++ b/src/food-market.web/src/components/ProductImageGallery.tsx @@ -0,0 +1,164 @@ +import { useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Trash2, Star, Upload, ChevronLeft, ChevronRight, X } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' + +interface ImageDto { id: string; url: string; isMain: boolean; sortOrder: number } + +interface Props { productId: string } + +export function ProductImageGallery({ productId }: Props) { + const qc = useQueryClient() + const fileInput = useRef(null) + const [lightboxIdx, setLightboxIdx] = useState(null) + + const url = `/api/catalog/products/${productId}/images` + + const list = useQuery({ + queryKey: [url], + queryFn: async () => (await api.get(url)).data, + }) + + const upload = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData() + fd.append('file', file) + return (await api.post(url, fd, { headers: { 'Content-Type': 'multipart/form-data' } })).data + }, + onSuccess: () => qc.invalidateQueries({ queryKey: [url] }), + }) + + const remove = useMutation({ + mutationFn: async (id: string) => { await api.delete(`${url}/${id}`) }, + onSuccess: () => qc.invalidateQueries({ queryKey: [url] }), + }) + + const setMain = useMutation({ + mutationFn: async (id: string) => { await api.post(`${url}/${id}/main`) }, + onSuccess: () => qc.invalidateQueries({ queryKey: [url] }), + }) + + const images = list.data ?? [] + + const onFiles = async (files: FileList | null) => { + if (!files) return + for (const f of Array.from(files)) { + await upload.mutateAsync(f) + } + if (fileInput.current) fileInput.current.value = '' + } + + const closeLb = () => setLightboxIdx(null) + const lbPrev = () => setLightboxIdx((i) => (i === null ? null : (i - 1 + images.length) % images.length)) + const lbNext = () => setLightboxIdx((i) => (i === null ? null : (i + 1) % images.length)) + + return ( +
+
+ onFiles(e.target.files)} + /> + + JPG/PNG/WEBP/GIF, до 10 МБ +
+ + {images.length === 0 ? ( +
Изображений нет.
+ ) : ( +
+ {images.map((img, i) => ( +
+ + {img.isMain && ( +
основное
+ )} +
+ {!img.isMain && ( + + )} + +
+
+ ))} +
+ )} + + {lightboxIdx !== null && images[lightboxIdx] && ( +
+ + {images.length > 1 && ( + <> + + + + )} + e.stopPropagation()} + /> +
+ {lightboxIdx + 1} / {images.length} +
+
+ )} +
+ ) +} diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 48bef5e..973f876 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -10,6 +10,7 @@ import { } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' import { BarcodeType, Packaging, type Product } from '@/lib/types' +import { ProductImageGallery } from '@/components/ProductImageGallery' interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string } interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean } @@ -269,9 +270,6 @@ export function ProductEditPage() { {suppliers.data?.map((c) => )} - - setForm({ ...form, imageUrl: e.target.value })} /> - @@ -359,6 +357,12 @@ export function ProductEditPage() { )} + {!isNew && id && ( +
+ +
+ )} +
Добавить}