Photos for vehicles
This commit is contained in:
@@ -9,6 +9,11 @@ import { VehiclesRepository } from '../data/vehicles.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
|
||||
import { getStorageService } from '../../../core/storage/storage.service';
|
||||
import { Transform, TransformCallback, Readable } from 'stream';
|
||||
import crypto from 'crypto';
|
||||
import FileType from 'file-type';
|
||||
import path from 'path';
|
||||
|
||||
export class VehiclesController {
|
||||
private vehiclesService: VehiclesService;
|
||||
@@ -291,4 +296,300 @@ export class VehiclesController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user.sub;
|
||||
const vehicleId = request.params.id;
|
||||
|
||||
logger.info('Vehicle image upload requested', {
|
||||
operation: 'vehicles.uploadImage',
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Verify vehicle ownership
|
||||
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
|
||||
if (!vehicle) {
|
||||
logger.warn('Vehicle not found for image upload', {
|
||||
operation: 'vehicles.uploadImage.not_found',
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
|
||||
}
|
||||
|
||||
const mp = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!mp) {
|
||||
logger.warn('No file provided for image upload', {
|
||||
operation: 'vehicles.uploadImage.no_file',
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
|
||||
}
|
||||
|
||||
// Allowed image types
|
||||
const allowedTypes = new Map([
|
||||
['image/jpeg', new Set(['image/jpeg'])],
|
||||
['image/png', new Set(['image/png'])],
|
||||
]);
|
||||
|
||||
const contentType = mp.mimetype as string | undefined;
|
||||
if (!contentType || !allowedTypes.has(contentType)) {
|
||||
logger.warn('Unsupported image type', {
|
||||
operation: 'vehicles.uploadImage.unsupported_type',
|
||||
userId,
|
||||
vehicleId,
|
||||
contentType,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'Only JPEG and PNG images are allowed'
|
||||
});
|
||||
}
|
||||
|
||||
// Read first 4100 bytes to detect file type via magic bytes
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
const targetBytes = 4100;
|
||||
|
||||
for await (const chunk of mp.file) {
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes >= targetBytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const headerBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Validate actual file content using magic bytes
|
||||
const detectedType = await FileType.fromBuffer(headerBuffer);
|
||||
|
||||
if (!detectedType) {
|
||||
logger.warn('Unable to detect file type from content', {
|
||||
operation: 'vehicles.uploadImage.type_detection_failed',
|
||||
userId,
|
||||
vehicleId,
|
||||
contentType,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'Unable to verify file type from content'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify detected type matches claimed Content-Type
|
||||
const allowedDetectedTypes = allowedTypes.get(contentType);
|
||||
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
|
||||
logger.warn('File content does not match Content-Type header', {
|
||||
operation: 'vehicles.uploadImage.type_mismatch',
|
||||
userId,
|
||||
vehicleId,
|
||||
claimedType: contentType,
|
||||
detectedType: detectedType.mime,
|
||||
fileName: mp.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: `File content (${detectedType.mime}) does not match claimed type (${contentType})`
|
||||
});
|
||||
}
|
||||
|
||||
// Delete existing image if present
|
||||
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
|
||||
const storage = getStorageService();
|
||||
try {
|
||||
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
|
||||
logger.info('Deleted existing vehicle image', {
|
||||
operation: 'vehicles.uploadImage.delete_existing',
|
||||
userId,
|
||||
vehicleId,
|
||||
storageKey: vehicle.imageStorageKey,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('Failed to delete existing vehicle image', {
|
||||
operation: 'vehicles.uploadImage.delete_existing_failed',
|
||||
userId,
|
||||
vehicleId,
|
||||
storageKey: vehicle.imageStorageKey,
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const originalName: string = mp.filename || 'vehicle-image';
|
||||
const ext = contentType === 'image/jpeg' ? 'jpg' : 'png';
|
||||
|
||||
class CountingStream extends Transform {
|
||||
public bytes = 0;
|
||||
override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) {
|
||||
this.bytes += chunk.length || 0;
|
||||
cb(null, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
const counter = new CountingStream();
|
||||
|
||||
// Create a new readable stream from the header buffer + remaining file chunks
|
||||
const headerStream = Readable.from([headerBuffer]);
|
||||
const remainingStream = mp.file;
|
||||
|
||||
// Pipe header first, then remaining content through counter
|
||||
headerStream.pipe(counter, { end: false });
|
||||
headerStream.on('end', () => {
|
||||
remainingStream.pipe(counter);
|
||||
});
|
||||
|
||||
const storage = getStorageService();
|
||||
const bucket = 'vehicle-images';
|
||||
const unique = crypto.randomBytes(32).toString('hex');
|
||||
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
|
||||
|
||||
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
|
||||
|
||||
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
|
||||
imageStorageBucket: bucket,
|
||||
imageStorageKey: key,
|
||||
imageFileName: originalName,
|
||||
imageContentType: contentType,
|
||||
imageFileSize: counter.bytes,
|
||||
});
|
||||
|
||||
logger.info('Vehicle image upload completed', {
|
||||
operation: 'vehicles.uploadImage.success',
|
||||
userId,
|
||||
vehicleId,
|
||||
fileName: originalName,
|
||||
contentType,
|
||||
detectedType: detectedType.mime,
|
||||
fileSize: counter.bytes,
|
||||
storageKey: key,
|
||||
});
|
||||
|
||||
return reply.code(200).send(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error uploading vehicle image', { error, vehicleId, userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to upload image'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user.sub;
|
||||
const vehicleId = request.params.id;
|
||||
|
||||
logger.info('Vehicle image download requested', {
|
||||
operation: 'vehicles.downloadImage',
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
|
||||
if (!vehicle || !vehicle.imageStorageBucket || !vehicle.imageStorageKey) {
|
||||
logger.warn('Vehicle or image not found for download', {
|
||||
operation: 'vehicles.downloadImage.not_found',
|
||||
userId,
|
||||
vehicleId,
|
||||
hasVehicle: !!vehicle,
|
||||
hasStorageInfo: !!(vehicle?.imageStorageBucket && vehicle?.imageStorageKey),
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle image not found' });
|
||||
}
|
||||
|
||||
const storage = getStorageService();
|
||||
const contentType = vehicle.imageContentType || 'application/octet-stream';
|
||||
const filename = vehicle.imageFileName || path.basename(vehicle.imageStorageKey);
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
|
||||
|
||||
logger.info('Vehicle image download initiated', {
|
||||
operation: 'vehicles.downloadImage.success',
|
||||
userId,
|
||||
vehicleId,
|
||||
fileName: filename,
|
||||
contentType,
|
||||
fileSize: vehicle.imageFileSize,
|
||||
});
|
||||
|
||||
const stream = await storage.getObjectStream(vehicle.imageStorageBucket, vehicle.imageStorageKey);
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
logger.error('Error downloading vehicle image', { error, vehicleId, userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to download image'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user.sub;
|
||||
const vehicleId = request.params.id;
|
||||
|
||||
logger.info('Vehicle image delete requested', {
|
||||
operation: 'vehicles.deleteImage',
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
|
||||
if (!vehicle) {
|
||||
logger.warn('Vehicle not found for image delete', {
|
||||
operation: 'vehicles.deleteImage.not_found',
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
|
||||
}
|
||||
|
||||
// Delete file from storage if exists
|
||||
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
|
||||
const storage = getStorageService();
|
||||
try {
|
||||
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
|
||||
logger.info('Vehicle image file deleted from storage', {
|
||||
operation: 'vehicles.deleteImage.storage_cleanup',
|
||||
userId,
|
||||
vehicleId,
|
||||
storageKey: vehicle.imageStorageKey,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('Failed to delete vehicle image file from storage', {
|
||||
operation: 'vehicles.deleteImage.storage_cleanup_failed',
|
||||
userId,
|
||||
vehicleId,
|
||||
storageKey: vehicle.imageStorageKey,
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear image metadata from database
|
||||
await this.vehiclesService.updateVehicleImage(vehicleId, userId, null);
|
||||
|
||||
logger.info('Vehicle image deleted', {
|
||||
operation: 'vehicles.deleteImage.success',
|
||||
userId,
|
||||
vehicleId,
|
||||
hadFile: !!(vehicle.imageStorageKey),
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting vehicle image', { error, vehicleId, userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to delete image'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,25 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
|
||||
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// Vehicle image routes - must be before :id to avoid conflicts
|
||||
// POST /api/vehicles/:id/image - Upload vehicle image
|
||||
fastify.post<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: vehiclesController.uploadImage.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:id/image - Download vehicle image
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: vehiclesController.downloadImage.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// DELETE /api/vehicles/:id/image - Delete vehicle image
|
||||
fastify.delete<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: vehiclesController.deleteImage.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// Dynamic :id routes MUST come last to avoid matching specific paths like "dropdown"
|
||||
// GET /api/vehicles/:id - Get specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:id', {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { Vehicle, CreateVehicleRequest } from '../domain/vehicles.types';
|
||||
import { Vehicle, CreateVehicleRequest, VehicleImageMeta } from '../domain/vehicles.types';
|
||||
|
||||
export class VehiclesRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
@@ -161,6 +161,45 @@ export class VehiclesRepository {
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async updateImageMeta(id: string, userId: string, meta: VehicleImageMeta | null): Promise<Vehicle | null> {
|
||||
if (meta === null) {
|
||||
const query = `
|
||||
UPDATE vehicles SET
|
||||
image_storage_bucket = NULL,
|
||||
image_storage_key = NULL,
|
||||
image_file_name = NULL,
|
||||
image_content_type = NULL,
|
||||
image_file_size = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await this.pool.query(query, [id, userId]);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE vehicles SET
|
||||
image_storage_bucket = $1,
|
||||
image_storage_key = $2,
|
||||
image_file_name = $3,
|
||||
image_content_type = $4,
|
||||
image_file_size = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $6 AND user_id = $7
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await this.pool.query(query, [
|
||||
meta.imageStorageBucket,
|
||||
meta.imageStorageKey,
|
||||
meta.imageFileName,
|
||||
meta.imageContentType,
|
||||
meta.imageFileSize,
|
||||
id,
|
||||
userId
|
||||
]);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
private mapRow(row: any): Vehicle {
|
||||
return {
|
||||
@@ -183,6 +222,11 @@ export class VehiclesRepository {
|
||||
deletedAt: row.deleted_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
imageStorageBucket: row.image_storage_bucket,
|
||||
imageStorageKey: row.image_storage_key,
|
||||
imageFileName: row.image_file_name,
|
||||
imageContentType: row.image_content_type,
|
||||
imageFileSize: row.image_file_size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Vehicle,
|
||||
CreateVehicleRequest,
|
||||
UpdateVehicleRequest,
|
||||
VehicleResponse
|
||||
VehicleResponse,
|
||||
VehicleImageMeta
|
||||
} from './vehicles.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
@@ -139,6 +140,23 @@ export class VehiclesService {
|
||||
await this.invalidateUserCache(userId);
|
||||
}
|
||||
|
||||
async getVehicleRaw(id: string, userId: string): Promise<Vehicle | null> {
|
||||
const vehicle = await this.repository.findById(id);
|
||||
if (!vehicle || vehicle.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
async updateVehicleImage(id: string, userId: string, meta: VehicleImageMeta | null): Promise<VehicleResponse | null> {
|
||||
const updated = await this.repository.updateImageMeta(id, userId, meta);
|
||||
if (!updated) {
|
||||
return null;
|
||||
}
|
||||
await this.invalidateUserCache(userId);
|
||||
return this.toResponse(updated);
|
||||
}
|
||||
|
||||
private async invalidateUserCache(userId: string): Promise<void> {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
await cacheService.del(cacheKey);
|
||||
@@ -227,6 +245,7 @@ export class VehiclesService {
|
||||
isActive: vehicle.isActive,
|
||||
createdAt: vehicle.createdAt.toISOString(),
|
||||
updatedAt: vehicle.updatedAt.toISOString(),
|
||||
imageUrl: vehicle.imageStorageKey ? `/api/vehicles/${vehicle.id}/image` : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface Vehicle {
|
||||
deletedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
imageStorageBucket?: string;
|
||||
imageStorageKey?: string;
|
||||
imageFileName?: string;
|
||||
imageContentType?: string;
|
||||
imageFileSize?: number;
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
@@ -73,6 +78,15 @@ export interface VehicleResponse {
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface VehicleImageMeta {
|
||||
imageStorageBucket: string;
|
||||
imageStorageKey: string;
|
||||
imageFileName: string;
|
||||
imageContentType: string;
|
||||
imageFileSize: number;
|
||||
}
|
||||
|
||||
export interface VINDecodeResult {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add image metadata columns to vehicles table
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_storage_bucket VARCHAR(50);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_storage_key VARCHAR(500);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_file_name VARCHAR(255);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_content_type VARCHAR(100);
|
||||
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_file_size INTEGER;
|
||||
|
||||
COMMENT ON COLUMN vehicles.image_storage_bucket IS 'Storage bucket for vehicle image';
|
||||
COMMENT ON COLUMN vehicles.image_storage_key IS 'Storage key path for vehicle image file';
|
||||
COMMENT ON COLUMN vehicles.image_file_name IS 'Original filename of uploaded image';
|
||||
COMMENT ON COLUMN vehicles.image_content_type IS 'MIME type of vehicle image';
|
||||
COMMENT ON COLUMN vehicles.image_file_size IS 'Size of vehicle image in bytes';
|
||||
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm
Normal file
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm
Normal file
Binary file not shown.
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal
Normal file
BIN
data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal
Normal file
Binary file not shown.
@@ -61,5 +61,22 @@ export const vehiclesApi = {
|
||||
getTrims: async (year: number, make: string, model: string): Promise<string[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImage: async (vehicleId: string, file: File): Promise<Vehicle> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteImage: async (vehicleId: string): Promise<void> => {
|
||||
await apiClient.delete(`/vehicles/${vehicleId}/image`);
|
||||
},
|
||||
|
||||
getImageUrl: (vehicleId: string): string => {
|
||||
return `/api/vehicles/${vehicleId}/image`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
import { VehicleImage } from './VehicleImage';
|
||||
|
||||
interface VehicleCardProps {
|
||||
vehicle: Vehicle;
|
||||
@@ -16,20 +17,6 @@ interface VehicleCardProps {
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
||||
<Box
|
||||
sx={{
|
||||
height: 96,
|
||||
bgcolor: color,
|
||||
borderRadius: 2,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
vehicle,
|
||||
onEdit,
|
||||
@@ -57,7 +44,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
<CardContent>
|
||||
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
||||
<VehicleImage vehicle={vehicle} height={96} />
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{displayName}
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { CreateVehicleRequest } from '../types/vehicles.types';
|
||||
import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
import { VehicleImageUpload } from './VehicleImageUpload';
|
||||
|
||||
const vehicleSchema = z
|
||||
.object({
|
||||
@@ -57,8 +58,9 @@ const vehicleSchema = z
|
||||
interface VehicleFormProps {
|
||||
onSubmit: (data: CreateVehicleRequest) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<CreateVehicleRequest>;
|
||||
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
|
||||
loading?: boolean;
|
||||
onImageUpdate?: (vehicle: Vehicle) => void;
|
||||
}
|
||||
|
||||
export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
@@ -66,6 +68,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
onCancel,
|
||||
initialData,
|
||||
loading,
|
||||
onImageUpdate,
|
||||
}) => {
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const [makes, setMakes] = useState<string[]>([]);
|
||||
@@ -80,6 +83,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
const isInitializing = useRef(false);
|
||||
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
||||
|
||||
const isEditMode = !!initialData?.id;
|
||||
const vehicleId = initialData?.id;
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -332,8 +339,53 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
}
|
||||
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
if (!vehicleId) return;
|
||||
const updated = await vehiclesApi.uploadImage(vehicleId, file);
|
||||
setCurrentImageUrl(updated.imageUrl);
|
||||
onImageUpdate?.(updated);
|
||||
};
|
||||
|
||||
const handleImageRemove = async () => {
|
||||
if (!vehicleId) return;
|
||||
await vehiclesApi.deleteImage(vehicleId);
|
||||
setCurrentImageUrl(undefined);
|
||||
if (initialData) {
|
||||
onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle);
|
||||
}
|
||||
};
|
||||
|
||||
const vehicleForImage: Vehicle = {
|
||||
id: vehicleId || '',
|
||||
userId: '',
|
||||
vin: initialData?.vin || '',
|
||||
make: initialData?.make,
|
||||
model: initialData?.model,
|
||||
year: initialData?.year,
|
||||
color: initialData?.color,
|
||||
odometerReading: initialData?.odometerReading || 0,
|
||||
isActive: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
imageUrl: currentImageUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{isEditMode && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Vehicle Photo
|
||||
</label>
|
||||
<VehicleImageUpload
|
||||
vehicle={vehicleForImage}
|
||||
onUpload={handleImageUpload}
|
||||
onRemove={handleImageRemove}
|
||||
disabled={loading || loadingDropdowns}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
VIN or License Plate <span className="text-red-500">*</span>
|
||||
|
||||
86
frontend/src/features/vehicles/components/VehicleImage.tsx
Normal file
86
frontend/src/features/vehicles/components/VehicleImage.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @ai-summary Vehicle image display with three-tier fallback
|
||||
* Tier 1: Custom uploaded image
|
||||
* Tier 2: Make logo from /images/makes/
|
||||
* Tier 3: Color box placeholder
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
|
||||
interface VehicleImageProps {
|
||||
vehicle: Vehicle;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
const getMakeLogoPath = (make?: string): string | null => {
|
||||
if (!make) return null;
|
||||
const normalized = make.toLowerCase().replace(/\s+/g, '-');
|
||||
return `/images/makes/${normalized}.png`;
|
||||
};
|
||||
|
||||
export const VehicleImage: React.FC<VehicleImageProps> = ({
|
||||
vehicle,
|
||||
height = 96,
|
||||
borderRadius = 2
|
||||
}) => {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [logoError, setLogoError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
setLogoError(false);
|
||||
}, [vehicle.id, vehicle.imageUrl]);
|
||||
|
||||
if (vehicle.imageUrl && !imgError) {
|
||||
return (
|
||||
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2 }}>
|
||||
<img
|
||||
src={vehicle.imageUrl}
|
||||
alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const logoPath = getMakeLogoPath(vehicle.make);
|
||||
if (logoPath && !logoError) {
|
||||
return (
|
||||
<Box sx={{
|
||||
height,
|
||||
borderRadius,
|
||||
bgcolor: '#F2EAEA',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
mb: 2
|
||||
}}>
|
||||
<img
|
||||
src={logoPath}
|
||||
alt={`${vehicle.make} logo`}
|
||||
style={{ maxHeight: '70%', maxWidth: '70%', objectFit: 'contain' }}
|
||||
onError={() => setLogoError(true)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
bgcolor: vehicle.color || '#F2EAEA',
|
||||
borderRadius,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 2
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
144
frontend/src/features/vehicles/components/VehicleImageUpload.tsx
Normal file
144
frontend/src/features/vehicles/components/VehicleImageUpload.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @ai-summary Vehicle image upload component with preview and validation
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, ChangeEvent } from 'react';
|
||||
import { Box, Button, CircularProgress, IconButton, Typography } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { VehicleImage } from './VehicleImage';
|
||||
|
||||
interface VehicleImageUploadProps {
|
||||
vehicle: Vehicle;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png'];
|
||||
|
||||
export const VehicleImageUpload: React.FC<VehicleImageUploadProps> = ({
|
||||
vehicle,
|
||||
onUpload,
|
||||
onRemove,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
setError('Please select a JPEG or PNG image');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError('Image must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setError(null);
|
||||
setRemoving(true);
|
||||
try {
|
||||
await onRemove();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove image');
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasImage = !!vehicle.imageUrl;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<VehicleImage vehicle={vehicle} height={120} />
|
||||
|
||||
{(uploading || removing) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} sx={{ color: 'white' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
disabled={disabled || uploading || removing}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<CloudUploadIcon />}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || uploading || removing}
|
||||
>
|
||||
{hasImage ? 'Change Photo' : 'Add Photo'}
|
||||
</Button>
|
||||
|
||||
{hasImage && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
disabled={disabled || uploading || removing}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
JPEG or PNG, max 5MB
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,7 @@ import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||
|
||||
// Theme colors now defined in Tailwind config
|
||||
import { VehicleImage } from '../components/VehicleImage';
|
||||
|
||||
interface VehicleDetailMobileProps {
|
||||
vehicle: Vehicle;
|
||||
@@ -31,19 +30,6 @@ const Section: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
||||
<Box
|
||||
sx={{
|
||||
height: 96,
|
||||
bgcolor: color,
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
vehicle,
|
||||
onBack,
|
||||
@@ -167,7 +153,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 3 }}>
|
||||
<Box sx={{ width: 112 }}>
|
||||
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
||||
<VehicleImage vehicle={vehicle} height={96} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import React from 'react';
|
||||
import { Card, CardActionArea, Box, Typography } from '@mui/material';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { VehicleImage } from '../components/VehicleImage';
|
||||
|
||||
interface VehicleMobileCardProps {
|
||||
vehicle: Vehicle;
|
||||
@@ -12,20 +13,6 @@ interface VehicleMobileCardProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
||||
<Box
|
||||
sx={{
|
||||
height: 120,
|
||||
bgcolor: color,
|
||||
borderRadius: 2,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
||||
vehicle,
|
||||
onClick,
|
||||
@@ -46,7 +33,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
||||
>
|
||||
<CardActionArea onClick={() => onClick?.(vehicle)}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
||||
<VehicleImage vehicle={vehicle} height={120} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Vehicle } from '../types/vehicles.types';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { VehicleForm } from '../components/VehicleForm';
|
||||
import { VehicleImage } from '../components/VehicleImage';
|
||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||
@@ -231,6 +232,7 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
initialData={vehicle}
|
||||
onSubmit={handleUpdateVehicle}
|
||||
onCancel={handleCancelEdit}
|
||||
onImageUpdate={(updated) => setVehicle(updated)}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
@@ -287,9 +289,25 @@ export const VehicleDetailPage: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 3, mb: 3 }}>
|
||||
<Box sx={{ width: 200, flexShrink: 0 }}>
|
||||
<VehicleImage vehicle={vehicle} height={150} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Vehicle Details
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{vehicle.year} {vehicle.make} {vehicle.model}
|
||||
{vehicle.trimLevel && ` ${vehicle.trimLevel}`}
|
||||
</Typography>
|
||||
{vehicle.vin && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
VIN: {vehicle.vin}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<form className="space-y-4">
|
||||
<DetailField
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Vehicle {
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
|
||||
Reference in New Issue
Block a user