Skip to content

Commit b1f110e

Browse files
Merge pull request #40 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Add a photograph to a vehicle from the panel
2 parents 3d0292d + bb81a02 commit b1f110e

27 files changed

Lines changed: 476 additions & 27 deletions

‎404.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
6969
rel="stylesheet"
7070
/>
71-
<script type="module" crossorigin src="/assets/index-BAszzJYG.js"></script>
71+
<script type="module" crossorigin src="/assets/index-6opXAa0y.js"></script>
7272
<link rel="stylesheet" crossorigin href="/assets/index-men8Xm6D.css">
7373
</head>
7474
<body>

‎about/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
6969
rel="stylesheet"
7070
/>
71-
<script type="module" crossorigin src="/assets/index-BAszzJYG.js"></script>
71+
<script type="module" crossorigin src="/assets/index-6opXAa0y.js"></script>
7272
<link rel="stylesheet" crossorigin href="/assets/index-men8Xm6D.css">
7373
</head>
7474
<body>

‎admin/admin.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ button { font-family: inherit; }
132132
.panel-header-link:hover { color: var(--primary); }
133133
.panel-header p { color: var(--ink-dim); font-size: 0.84rem; }
134134

135+
/* ---------- Vehicle photograph ---------- */
136+
.photo-field { display: flex; flex-direction: column; gap: 8px; }
137+
.photo-preview { display: flex; align-items: center; gap: 10px; }
138+
.photo-preview img {
139+
width: 104px; aspect-ratio: 16 / 10; object-fit: cover;
140+
border-radius: 8px; border: 1px solid var(--border); background: var(--bg);
141+
}
142+
.field-note { color: var(--ink-dim); font-size: 0.8rem; }
143+
.admin-car-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
144+
135145
/* ---------- Car admin grid ---------- */
136146
.car-admin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
137147
.car-admin-card {

‎admin/admin.js‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ function renderCarAdminGrid() {
6565

6666
wrap.innerHTML = cars.map((car) => `
6767
<div class="car-admin-card" data-id="${car.id}">
68-
<div class="admin-car-thumb">${carIllustrationSVG(car.bodyType, car.color)}</div>
68+
<div class="admin-car-thumb">${car.photo
69+
? `<img src="${car.photo}" alt="" loading="lazy">`
70+
: carIllustrationSVG(car.bodyType, car.color)}</div>
6971
<div class="row1">
7072
<span class="name">${car.name}</span>
7173
<span class="status-badge status-badge-${car.status.replace(' ', '')}">${car.status}</span>
@@ -148,9 +150,50 @@ function openCarModal(car) {
148150
document.getElementById('carExtraKmRate').value = car ? car.extraKmRate : '';
149151
document.getElementById('carSecurityDeposit').value = car ? car.securityDeposit : '';
150152
document.getElementById('carCurrentKm').value = car ? car.currentKm : 0;
153+
154+
// Reset by hand: form.reset() clears the file input but not the preview, and
155+
// a preview left from the last vehicle edited would be a picture of the
156+
// wrong car sitting above the right one's details.
157+
photoRemoved = false;
158+
document.getElementById('carPhoto').value = '';
159+
showCarPhoto(car && car.photo ? car.photo : null);
160+
151161
carModalOverlay.hidden = false;
152162
}
153163

164+
// Set when Remove is pressed, so Save knows to clear the photograph even
165+
// though nothing was chosen to replace it.
166+
let photoRemoved = false;
167+
168+
function showCarPhoto(url) {
169+
const preview = document.getElementById('carPhotoPreview');
170+
const img = document.getElementById('carPhotoPreviewImg');
171+
if (url) {
172+
img.src = url;
173+
preview.hidden = false;
174+
} else {
175+
img.removeAttribute('src');
176+
preview.hidden = true;
177+
}
178+
}
179+
180+
document.getElementById('carPhotoRemove').addEventListener('click', () => {
181+
// Nothing is deleted until Save. Pressing Remove and then Cancel should
182+
// leave the vehicle exactly as it was.
183+
photoRemoved = true;
184+
document.getElementById('carPhoto').value = '';
185+
showCarPhoto(null);
186+
});
187+
188+
// Show the chosen file straight away rather than after saving, so a wrong
189+
// picture is obvious before it is committed to anything.
190+
document.getElementById('carPhoto').addEventListener('change', (e) => {
191+
const file = e.target.files && e.target.files[0];
192+
if (!file) return;
193+
photoRemoved = false;
194+
showCarPhoto(URL.createObjectURL(file));
195+
});
196+
154197
function closeCarModal() {
155198
carModalOverlay.hidden = true;
156199
}
@@ -195,7 +238,16 @@ carForm.addEventListener('submit', async (e) => {
195238
try {
196239
// The server validates and stores; the panel re-reads rather than
197240
// guessing what was saved, so what is shown is what is recorded.
198-
await saveVehicle(carData);
241+
const saved = await saveVehicle(carData);
242+
243+
// Second, and only now: a photograph is stored against a vehicle id, and a
244+
// vehicle being created has none until the save above returns one.
245+
const chosen = document.getElementById('carPhoto').files[0] || null;
246+
const vehicleId = saved && saved.id ? saved.id : carData.id;
247+
if (vehicleId && (chosen || photoRemoved)) {
248+
await saveVehiclePhoto(vehicleId, chosen);
249+
}
250+
199251
closeCarModal();
200252
renderCarAdminGrid();
201253
renderOverview();

‎admin/api.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,53 @@ async function apiRequest(url, { method = 'GET', body = null } = {}) {
5656
return payload;
5757
}
5858

59+
/**
60+
* The same handling as apiRequest, for a request carrying a file.
61+
*
62+
* apiRequest sets Content-Type and JSON-encodes the body, and both are wrong
63+
* for an upload: the browser has to set the multipart boundary itself, so the
64+
* header must be left alone entirely.
65+
*/
66+
async function apiUpload(url, formData) {
67+
formData.append('csrf_token', CSRF_TOKEN);
68+
69+
let response;
70+
try {
71+
response = await fetch(url, {
72+
method: 'POST',
73+
headers: { 'Accept': 'application/json', 'X-CSRF-Token': CSRF_TOKEN },
74+
credentials: 'same-origin',
75+
body: formData,
76+
});
77+
} catch {
78+
throw new ApiError('Could not reach the server. Check your connection.', 0);
79+
}
80+
81+
if (response.status === 401) {
82+
window.location.href = 'index.php';
83+
throw new ApiError('Signed out', 401);
84+
}
85+
86+
let payload = {};
87+
try {
88+
payload = await response.json();
89+
} catch {
90+
// An upload larger than the server accepts is often refused before PHP
91+
// runs, so the reply is the host's HTML error page rather than our JSON.
92+
throw new ApiError(
93+
response.status === 413
94+
? 'That image is larger than the server will accept. Try one under about 5 MB.'
95+
: 'The server returned an unreadable response.',
96+
response.status,
97+
);
98+
}
99+
100+
if (!response.ok) {
101+
throw new ApiError(payload.error || 'Something went wrong.', response.status, payload.fields);
102+
}
103+
return payload;
104+
}
105+
59106
const api = {
60107
vehicles: {
61108
list: (includeInactive = false) =>
@@ -64,6 +111,16 @@ const api = {
64111
apiRequest('api/vehicles.php?action=save', { method: 'POST', body: vehicle }),
65112
retire: (id, reason) =>
66113
apiRequest('api/vehicles.php?action=retire', { method: 'POST', body: { id, reason } }),
114+
photo: (id, file) => {
115+
const form = new FormData();
116+
form.append('vehicle_id', String(id));
117+
if (file) {
118+
form.append('photo', file);
119+
} else {
120+
form.append('remove', '1');
121+
}
122+
return apiUpload('api/vehicle-photo.php', form);
123+
},
67124
},
68125

69126
bookings: {

‎admin/api/public-vehicles.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
require_once __DIR__ . '/../src/db.php';
55
require_once __DIR__ . '/../src/http.php';
6+
require_once __DIR__ . '/../src/vehicle-photos.php';
67

78
/**
89
* The fleet, for the public website.
@@ -60,6 +61,7 @@
6061
"SELECT v.id,
6162
v.name,
6263
v.brand,
64+
v.photo_file,
6365
v.body_type,
6466
v.fuel,
6567
v.transmission,
@@ -96,6 +98,9 @@
9698
'brand' => (string) $row['brand'],
9799
'name' => (string) $row['name'],
98100
'bodyType' => (string) $row['body_type'],
101+
// Relative to the panel, which is where the site asks for vehicles, so
102+
// it resolves the same way whatever the site is served from.
103+
'photo' => vehicle_photo_url($row['photo_file'] ?? null),
99104
'fuel' => (string) $row['fuel'],
100105
'transmission' => (string) $row['transmission'],
101106
'seats' => (int) $row['seats'],

‎admin/api/vehicle-photo.php‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
require_once __DIR__ . '/../src/http.php';
5+
require_once __DIR__ . '/../src/vehicle-photos.php';
6+
7+
/**
8+
* Uploading and removing a vehicle's photograph.
9+
*
10+
* Separate from vehicles.php because this one takes multipart form data rather
11+
* than JSON, and folding a file upload into an endpoint that parses a JSON body
12+
* would mean the body being read two different ways depending on the request.
13+
*
14+
* Same guard as every other write: signed in, permitted, POST, CSRF token.
15+
* api_guard reads the token from X-CSRF-Token or a csrf_token field, and a
16+
* multipart form can carry either.
17+
*/
18+
19+
$user = api_guard('vehicle.edit', true);
20+
21+
$vehicleId = (int) ($_POST['vehicle_id'] ?? 0);
22+
if ($vehicleId <= 0) {
23+
json_error('Which vehicle this belongs to was not sent.', 422);
24+
}
25+
26+
$vehicle = fetch_one('SELECT id, name, photo_file FROM vehicles WHERE id = ?', [$vehicleId]);
27+
if ($vehicle === null) {
28+
json_error('That vehicle no longer exists.', 404);
29+
}
30+
31+
$previous = $vehicle['photo_file'] !== null ? (string) $vehicle['photo_file'] : null;
32+
33+
// ------------------------------------------------------------- removal --
34+
if (($_POST['remove'] ?? '') === '1') {
35+
query('UPDATE vehicles SET photo_file = NULL WHERE id = ?', [$vehicleId]);
36+
vehicle_photo_delete($previous);
37+
38+
audit_log('vehicle_photo_removed', 'vehicles', 'vehicle', $vehicleId,
39+
['photo_file' => $previous], ['photo_file' => null], null,
40+
(int) $user['id'], $user['name'], null, null, $vehicleId);
41+
42+
json_out(['ok' => true, 'photo' => null]);
43+
}
44+
45+
// -------------------------------------------------------------- upload --
46+
$file = $_FILES['photo'] ?? null;
47+
if (!is_array($file)) {
48+
json_error('No image was attached.', 422);
49+
}
50+
51+
[$extension, $problem] = vehicle_photo_check($file);
52+
if ($problem !== null) {
53+
json_error($problem, 422);
54+
}
55+
56+
try {
57+
$stored = vehicle_photo_store($vehicleId, $file, (string) $extension);
58+
} catch (RuntimeException $e) {
59+
error_log('vehicle photo store failed: ' . $e->getMessage());
60+
json_error('The image could not be saved on the server.', 500);
61+
}
62+
63+
query('UPDATE vehicles SET photo_file = ? WHERE id = ?', [$stored, $vehicleId]);
64+
65+
// Only after the row points at the new file. The other order would leave a
66+
// vehicle pointing at a file that had just been deleted if the update failed.
67+
vehicle_photo_delete($previous);
68+
69+
audit_log('vehicle_photo_changed', 'vehicles', 'vehicle', $vehicleId,
70+
['photo_file' => $previous], ['photo_file' => $stored], null,
71+
(int) $user['id'], $user['name'], null, null, $vehicleId);
72+
73+
json_out(['ok' => true, 'photo' => vehicle_photo_url($stored)]);

‎admin/api/vehicles.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
declare(strict_types=1);
33

44
require_once __DIR__ . '/../src/http.php';
5+
require_once __DIR__ . '/../src/vehicle-photos.php';
56

67
/**
78
* Vehicles.
@@ -254,6 +255,7 @@ function present_vehicle(array $row): array
254255
'seats' => (int) $row['seats'],
255256
'model_year' => (int) $row['model_year'],
256257
'colour' => $row['colour'],
258+
'photo' => vehicle_photo_url($row['photo_file'] ?? null),
257259
'status' => $row['status'],
258260
'current_km' => (int) $row['current_km'],
259261
'rate_daily' => isset($row['rate_daily']) ? (float) $row['rate_daily'] : 0.0,

‎admin/car-data.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ function toPanelShape(v) {
3737
kmLimitPerDay: v.km_limit_per_day,
3838
extraKmRate: v.extra_km_rate,
3939
securityDeposit: v.security_deposit,
40+
// Read-only here. Photographs are uploaded through their own endpoint,
41+
// because saving a vehicle sends JSON and a file cannot travel in it.
42+
photo: v.photo || null,
4043
};
4144
}
4245

@@ -82,6 +85,18 @@ async function saveVehicle(car) {
8285
return result.vehicle;
8386
}
8487

88+
/**
89+
* Uploads or removes a vehicle's photograph.
90+
*
91+
* Kept apart from saveVehicle because it needs a vehicle id, and a vehicle
92+
* being created does not have one until the save returns. The form does both
93+
* in order so that is invisible to whoever is filling it in.
94+
*/
95+
async function saveVehiclePhoto(vehicleId, file) {
96+
await api.vehicles.photo(vehicleId, file);
97+
await refreshVehicles();
98+
}
99+
85100
/**
86101
* Changes a few fields on a vehicle and saves it. Used by the pickup and
87102
* return flows, which move a vehicle between statuses and write back the

‎admin/dashboard.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,23 @@
455455
<input type="number" id="carCurrentKm" required min="0" placeholder="4200" />
456456
</div>
457457

458+
<!-- The photograph is uploaded after the vehicle is saved, because it
459+
is stored against a vehicle id and a new vehicle has none yet. The
460+
form hides that: pick a file, press Save, and both happen. -->
461+
<div class="field-group">
462+
<label for="carPhoto">Photograph</label>
463+
<div class="photo-field">
464+
<div class="photo-preview" id="carPhotoPreview" hidden>
465+
<img alt="" id="carPhotoPreviewImg" />
466+
<button type="button" class="btn btn-danger btn-sm" id="carPhotoRemove">Remove</button>
467+
</div>
468+
<input type="file" id="carPhoto" accept="image/jpeg,image/png,image/webp,image/avif" />
469+
<p class="field-note">Shown on the website's fleet cards. JPG, PNG, WebP or AVIF,
470+
up to 6&nbsp;MB. A landscape photo of the whole car works best &mdash; it is
471+
cropped to a wide rectangle.</p>
472+
</div>
473+
</div>
474+
458475
<div class="modal-actions">
459476
<button type="button" class="btn btn-ghost" id="carModalCancel">Cancel</button>
460477
<button type="submit" class="btn btn-primary">Save Vehicle</button>

0 commit comments

Comments
 (0)