Fase 3: Soporte de Imágenes en Wizard y Backend (Upload Local)
This commit is contained in:
78
frontend/publish-wizard/package-lock.json
generated
78
frontend/publish-wizard/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -1781,6 +1782,15 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
@@ -2430,6 +2440,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -2791,7 +2813,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -2901,6 +2922,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3018,6 +3051,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -3167,6 +3209,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -3204,6 +3257,29 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useWizardStore } from './store/wizardStore';
|
||||
import CategorySelection from './pages/Steps/CategorySelection';
|
||||
import OperationSelection from './pages/Steps/OperationSelection';
|
||||
import AttributeForm from './pages/Steps/AttributeForm';
|
||||
import PhotoUploadStep from './pages/Steps/PhotoUploadStep';
|
||||
import SummaryStep from './pages/Steps/SummaryStep';
|
||||
import { wizardService } from './services/wizardService';
|
||||
import type { AttributeDefinition } from './types';
|
||||
@@ -41,7 +42,7 @@ function App() {
|
||||
{step === 1 && <CategorySelection />}
|
||||
{step === 2 && <OperationSelection />}
|
||||
{step === 3 && <AttributeForm />}
|
||||
{step === 4 && <div className="text-center py-20">Paso 4: Fotos (Coming Soon) - <button onClick={() => useWizardStore.getState().setStep(5)} className="text-blue-500 underline">Saltar</button></div>}
|
||||
{step === 4 && <PhotoUploadStep />}
|
||||
{step === 5 && <SummaryStep definitions={definitions} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
84
frontend/publish-wizard/src/pages/Steps/PhotoUploadStep.tsx
Normal file
84
frontend/publish-wizard/src/pages/Steps/PhotoUploadStep.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
|
||||
export default function PhotoUploadStep() {
|
||||
const { setStep } = useWizardStore();
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<h2 className="text-2xl font-bold mb-2 text-brand-900">Fotos del Aviso</h2>
|
||||
<p className="text-slate-500 mb-6">Muestra lo mejor de tu producto. Primera foto es la portada.</p>
|
||||
|
||||
<DropzoneArea />
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
onClick={() => setStep(5)}
|
||||
className="bg-brand-600 text-white px-6 py-3 rounded-lg font-bold hover:bg-brand-700"
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function DropzoneArea() {
|
||||
const { addPhoto, removePhoto, photos } = useWizardStore();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
acceptedFiles.forEach(file => {
|
||||
addPhoto(file);
|
||||
});
|
||||
}, [addPhoto]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'image/*': [] },
|
||||
maxFiles: 10
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors
|
||||
${isDragActive ? 'border-brand-500 bg-brand-50' : 'border-slate-300 hover:border-brand-400'}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-2 text-slate-500">
|
||||
<Upload size={40} className="text-slate-400" />
|
||||
<p>Arrastra fotos aquí, o click para seleccionar</p>
|
||||
<p className="text-xs">Soporta JPG, PNG, WEBP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previews */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-4 mt-6">
|
||||
{photos.map((file: File, index: number) => (
|
||||
<div key={index} className="relative aspect-square bg-slate-100 rounded-lg overflow-hidden border border-slate-200 group">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt="preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removePhoto(index)}
|
||||
className="absolute top-1 right-1 bg-black/50 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{index === 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-brand-600/80 text-white text-xs py-1 text-center">
|
||||
Portada
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,15 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
|
||||
};
|
||||
|
||||
const result = await wizardService.createListing(payload);
|
||||
|
||||
// Upload Images
|
||||
const { photos } = useWizardStore.getState();
|
||||
if (photos.length > 0) {
|
||||
for (const photo of photos) {
|
||||
await wizardService.uploadImage(result.id, photo);
|
||||
}
|
||||
}
|
||||
|
||||
setCreatedId(result.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -20,5 +20,14 @@ export const wizardService = {
|
||||
createListing: async (data: any): Promise<{ id: number }> => {
|
||||
const response = await api.post<{ id: number }>('/listings', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImage: async (listingId: number, file: File): Promise<{ url: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await api.post<{ url: string }>(`/images/upload/${listingId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,10 +6,13 @@ interface WizardState {
|
||||
selectedCategory: Category | null;
|
||||
selectedOperation: Operation | null;
|
||||
attributes: Record<string, any>;
|
||||
photos: File[]; // Added
|
||||
setStep: (step: number) => void;
|
||||
setCategory: (category: Category) => void;
|
||||
setOperation: (operation: Operation) => void;
|
||||
setAttribute: (key: string, value: any) => void;
|
||||
addPhoto: (file: File) => void; // Added
|
||||
removePhoto: (index: number) => void; // Added
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -18,11 +21,14 @@ export const useWizardStore = create<WizardState>((set) => ({
|
||||
selectedCategory: null,
|
||||
selectedOperation: null,
|
||||
attributes: {},
|
||||
photos: [],
|
||||
setStep: (step) => set({ step }),
|
||||
setCategory: (category) => set({ selectedCategory: category, step: 2 }), // Auto advance
|
||||
setOperation: (operation) => set({ selectedOperation: operation, step: 3 }), // Auto advance
|
||||
setAttribute: (key, value) => set((state) => ({
|
||||
attributes: { ...state.attributes, [key]: value }
|
||||
})),
|
||||
reset: () => set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {} }),
|
||||
addPhoto: (file) => set((state) => ({ photos: [...state.photos, file] })),
|
||||
removePhoto: (index) => set((state) => ({ photos: state.photos.filter((_, i) => i !== index) })),
|
||||
reset: () => set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {}, photos: [] }),
|
||||
}));
|
||||
|
||||
56
src/SIGCM.API/Controllers/ImagesController.cs
Normal file
56
src/SIGCM.API/Controllers/ImagesController.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ImagesController : ControllerBase
|
||||
{
|
||||
private readonly IImageRepository _repository;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public ImagesController(IImageRepository repository, IWebHostEnvironment env)
|
||||
{
|
||||
_repository = repository;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
[HttpPost("upload/{listingId}")]
|
||||
public async Task<IActionResult> Upload(int listingId, IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0) return BadRequest("File is empty");
|
||||
|
||||
// Basic validation
|
||||
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".webp" };
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!allowedExtensions.Contains(ext)) return BadRequest("Invalid file type");
|
||||
|
||||
// Ensure directory exists
|
||||
var uploadDir = Path.Combine(_env.WebRootPath, "uploads", "listings", listingId.ToString());
|
||||
Directory.CreateDirectory(uploadDir);
|
||||
|
||||
// Save file
|
||||
var fileName = $"{Guid.NewGuid()}{ext}";
|
||||
var filePath = Path.Combine(uploadDir, fileName);
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
var relativeUrl = $"/uploads/listings/{listingId}/{fileName}";
|
||||
var image = new ListingImage
|
||||
{
|
||||
ListingId = listingId,
|
||||
Url = relativeUrl,
|
||||
IsMainInfo = false, // Logic to set first as main could be added here
|
||||
DisplayOrder = 0
|
||||
};
|
||||
await _repository.AddAsync(image);
|
||||
|
||||
return Ok(new { Url = relativeUrl });
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles(); // Enable static files for images
|
||||
|
||||
app.UseCors("AllowFrontend");
|
||||
|
||||
|
||||
10
src/SIGCM.Domain/Entities/ListingImage.cs
Normal file
10
src/SIGCM.Domain/Entities/ListingImage.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class ListingImage
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ListingId { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public bool IsMainInfo { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
}
|
||||
9
src/SIGCM.Domain/Interfaces/IImageRepository.cs
Normal file
9
src/SIGCM.Domain/Interfaces/IImageRepository.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using SIGCM.Domain.Entities;
|
||||
|
||||
namespace SIGCM.Domain.Interfaces;
|
||||
|
||||
public interface IImageRepository
|
||||
{
|
||||
Task AddAsync(ListingImage image);
|
||||
Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId);
|
||||
}
|
||||
@@ -154,6 +154,18 @@ BEGIN
|
||||
FOREIGN KEY (AttributeDefinitionId) REFERENCES AttributeDefinitions(Id) ON DELETE NO ACTION
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ListingImages')
|
||||
BEGIN
|
||||
CREATE TABLE ListingImages (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
ListingId INT NOT NULL,
|
||||
Url NVARCHAR(500) NOT NULL,
|
||||
IsMainInfo BIT DEFAULT 0,
|
||||
DisplayOrder INT DEFAULT 0,
|
||||
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE
|
||||
);
|
||||
END
|
||||
";
|
||||
await connection.ExecuteAsync(schemaSql);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAuthService, Services.AuthService>();
|
||||
services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>();
|
||||
services.AddScoped<IListingRepository, ListingRepository>();
|
||||
services.AddScoped<IImageRepository, ImageRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
33
src/SIGCM.Infrastructure/Repositories/ImageRepository.cs
Normal file
33
src/SIGCM.Infrastructure/Repositories/ImageRepository.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class ImageRepository : IImageRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public ImageRepository(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task AddAsync(ListingImage image)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO ListingImages (ListingId, Url, IsMainInfo, DisplayOrder)
|
||||
VALUES (@ListingId, @Url, @IsMainInfo, @DisplayOrder)";
|
||||
await conn.ExecuteAsync(sql, image);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryAsync<ListingImage>(
|
||||
"SELECT * FROM ListingImages WHERE ListingId = @ListingId ORDER BY DisplayOrder",
|
||||
new { ListingId = listingId });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user