Photos for vehicles

This commit is contained in:
Eric Gullickson
2025-12-15 21:39:51 -06:00
parent e1c48b7a26
commit 263fc434b0
17 changed files with 745 additions and 58 deletions

View File

@@ -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'
});
}
}
}

View File

@@ -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', {

View File

@@ -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) {}
@@ -156,11 +156,50 @@ export class VehiclesRepository {
SET is_active = false, deleted_at = NOW()
WHERE id = $1
`;
const result = await this.pool.query(query, [id]);
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,
};
}
}

View File

@@ -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';
@@ -131,14 +132,31 @@ export class VehiclesService {
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
// Soft delete
await this.repository.softDelete(id);
// Invalidate cache
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,
};
}
}

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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`;
}
};

View File

@@ -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}

View File

@@ -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>

View 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
}}
/>
);
};

View 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>
);
};

View File

@@ -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 }}>

View File

@@ -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>

View File

@@ -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,10 +289,26 @@ export const VehicleDetailPage: React.FC = () => {
</Box>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Vehicle Details
</Typography>
<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
label="VIN or License Plate"

View File

@@ -21,6 +21,7 @@ export interface Vehicle {
isActive: boolean;
createdAt: string;
updatedAt: string;
imageUrl?: string;
}
export interface CreateVehicleRequest {