Skip to content

Commit 341232c

Browse files
Merge pull request #48 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Places to visit in Kanyakumari: a page, a home section, and a panel to edit it
2 parents c7c7955 + 920de02 commit 341232c

36 files changed

Lines changed: 854 additions & 37 deletions

‎.site-manifest.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"nri",
1818
"open-road.webp",
1919
"photos",
20+
"places",
2021
"robots.txt",
2122
"sitemap.xml",
2223
"tariff",

‎404.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
3838
rel="stylesheet"
3939
/>
40-
<script type="module" crossorigin src="/assets/index-BNprFGBJ.js"></script>
41-
<link rel="stylesheet" crossorigin href="/assets/index-BTimzaQ8.css">
40+
<script type="module" crossorigin src="/assets/index-BjCbFD9f.js"></script>
41+
<link rel="stylesheet" crossorigin href="/assets/index-DCYIfxSq.css">
4242
</head>
4343
<body>
4444
<div id="root"></div>

‎about/index.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
3838
rel="stylesheet"
3939
/>
40-
<script type="module" crossorigin src="/assets/index-BNprFGBJ.js"></script>
41-
<link rel="stylesheet" crossorigin href="/assets/index-BTimzaQ8.css">
40+
<script type="module" crossorigin src="/assets/index-BjCbFD9f.js"></script>
41+
<link rel="stylesheet" crossorigin href="/assets/index-DCYIfxSq.css">
4242
</head>
4343
<body>
4444
<div id="root"></div>

‎admin/admin.css‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,27 @@ button { font-family: inherit; }
140140
}
141141
.migration-warning span { display: block; color: var(--ink-dim); font-size: 0.82rem; margin-top: 2px; }
142142

143+
/* ---------- Places to visit ---------- */
144+
.place-list { display: flex; flex-direction: column; gap: 10px; }
145+
.place-row {
146+
display: grid; grid-template-columns: 96px 1fr auto; gap: 14px; align-items: start;
147+
padding: 12px; border: 1px solid var(--border); border-radius: 12px; background: #fff;
148+
}
149+
/* One shape whatever the photograph's own proportions, so the rows line up
150+
rather than stepping down the page. */
151+
.place-thumb {
152+
width: 96px; aspect-ratio: 16 / 10; border-radius: 8px; overflow: hidden;
153+
background: var(--bg); display: grid; place-items: center;
154+
}
155+
.place-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
156+
.place-body { min-width: 0; }
157+
.place-actions { display: flex; gap: 8px; align-items: center; }
158+
@media (max-width: 560px) {
159+
.place-row { grid-template-columns: 72px 1fr; }
160+
.place-thumb { width: 72px; }
161+
.place-actions { grid-column: 1 / -1; }
162+
}
163+
143164
/* ---------- Logo and mark uploads ---------- */
144165
.brand-slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
145166
.brand-slot { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }

‎admin/api/public-places.php‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
require_once __DIR__ . '/../src/db.php';
5+
require_once __DIR__ . '/../src/http.php';
6+
require_once __DIR__ . '/../src/places.php';
7+
8+
/**
9+
* Places worth driving to, for the public website.
10+
*
11+
* Read-only, no session, published rows only. Same shape of endpoint as
12+
* public-vehicles.php, and the same short cache: long enough to absorb a burst
13+
* of visitors, short enough that adding a place does not feel broken.
14+
*/
15+
16+
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
17+
json_error('Only GET is supported here.', 405);
18+
}
19+
20+
header('Cache-Control: public, max-age=60');
21+
22+
$places = [];
23+
foreach (places_all() as $row) {
24+
$places[] = [
25+
'id' => (string) $row['id'],
26+
'name' => (string) $row['name'],
27+
'category' => (string) $row['category'],
28+
'blurb' => (string) $row['blurb'],
29+
'mapUrl' => (string) $row['map_url'],
30+
// Relative to the panel, which is where the site asks -- resolved
31+
// against the panel and not the page by the site's own helper.
32+
'photo' => place_photo_url($row['photo_file'] ?? null),
33+
];
34+
}
35+
36+
json_out(['ok' => true, 'places' => $places]);

‎admin/dashboard.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
<div class="admin-header-actions">
5252
<span class="who"><?= e($me['name']) ?> <span class="who-role"><?= e($me['role_name']) ?></span></span>
5353
<a class="btn btn-ghost btn-sm" href="content.php">Website content</a>
54+
<a class="btn btn-ghost btn-sm" href="places.php">Places to visit</a>
5455
<form method="post" action="logout.php" class="logout-form">
5556
<?= csrf_field() ?>
5657
<button type="submit" class="btn btn-ghost btn-sm">Log Out</button>

‎admin/place-photo.php‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
require_once __DIR__ . '/src/places.php';
5+
6+
/**
7+
* Streams a place's photograph. Public, like the car photographs.
8+
*
9+
* Only names of the shape this code generates are answered, which is a better
10+
* rule than trying to enumerate the ways a path can escape a directory.
11+
*/
12+
13+
$name = (string) ($_GET['f'] ?? '');
14+
15+
if (!preg_match('/^p\d+-[0-9a-f]{16}\.(jpg|png|webp|avif)$/', $name, $m)) {
16+
http_response_code(404);
17+
header('Content-Type: text/plain; charset=utf-8');
18+
exit("Not found\n");
19+
}
20+
21+
$path = place_photo_dir() . '/' . $name;
22+
if (!is_file($path)) {
23+
http_response_code(404);
24+
header('Content-Type: text/plain; charset=utf-8');
25+
exit("Not found\n");
26+
}
27+
28+
header('Content-Type: ' . VEHICLE_PHOTO_MIME[$m[1]]);
29+
header('Content-Length: ' . (string) filesize($path));
30+
header('Cache-Control: public, max-age=31536000, immutable');
31+
header('X-Content-Type-Options: nosniff');
32+
header('Content-Disposition: inline');
33+
34+
readfile($path);

‎admin/places.php‎

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
require_once __DIR__ . '/src/csrf.php';
5+
require_once __DIR__ . '/src/assets.php';
6+
require_once __DIR__ . '/src/migrate.php';
7+
require_once __DIR__ . '/src/places.php';
8+
9+
/**
10+
* Places worth driving to.
11+
*
12+
* A page rather than a tab on the dashboard, for the same reason Website
13+
* content is one: it is edited occasionally and at length, not glanced at
14+
* while running the day. The dashboard is for today's bookings.
15+
*/
16+
17+
$me = require_login();
18+
19+
$migrationError = migrate_if_needed();
20+
21+
header('X-Frame-Options: DENY');
22+
header('X-Content-Type-Options: nosniff');
23+
header('Referrer-Policy: same-origin');
24+
25+
$notice = null;
26+
$error = $migrationError === null ? null
27+
: 'The database could not be brought up to date: ' . $migrationError;
28+
29+
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
30+
require_csrf();
31+
32+
$action = (string) ($_POST['action'] ?? '');
33+
34+
if ($action === 'delete') {
35+
place_delete((int) ($_POST['id'] ?? 0), (int) $me['id']);
36+
$notice = 'Removed.';
37+
} elseif ($action === 'save') {
38+
$problem = place_save($_POST, $_FILES['photo'] ?? null, (int) $me['id']);
39+
if ($problem !== null) {
40+
$error = $problem;
41+
} else {
42+
$notice = 'Saved. It appears on the site within a minute.';
43+
}
44+
}
45+
}
46+
47+
$places = places_all(true);
48+
$editing = null;
49+
if (isset($_GET['edit'])) {
50+
foreach ($places as $row) {
51+
if ((int) $row['id'] === (int) $_GET['edit']) {
52+
$editing = $row;
53+
}
54+
}
55+
}
56+
?>
57+
<!DOCTYPE html>
58+
<html lang="en">
59+
<head>
60+
<meta charset="UTF-8">
61+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
62+
<title>Places to visit — NiteSha Cars Admin</title>
63+
<meta name="robots" content="noindex, nofollow">
64+
<link rel="stylesheet" href="<?= asset('admin.css') ?>">
65+
</head>
66+
<body>
67+
<div class="c-wrap">
68+
69+
<div class="c-top">
70+
<div>
71+
<h1 style="margin:0;font-size:1.3rem">Places to visit</h1>
72+
<p class="c-note" style="margin:.3rem 0 0">
73+
Shown on the home page and on <strong>niteshacars.in/places</strong>. These are
74+
what people search for before they book a car, so it is worth keeping current.
75+
</p>
76+
</div>
77+
<a class="btn btn-ghost btn-sm" href="dashboard.php">Back to dashboard</a>
78+
</div>
79+
80+
<?php if ($notice !== null): ?><div class="c-msg c-ok"><?= e($notice) ?></div><?php endif; ?>
81+
<?php if ($error !== null): ?><div class="c-msg c-bad"><?= e($error) ?></div><?php endif; ?>
82+
83+
<form class="c-sec" method="post" enctype="multipart/form-data">
84+
<?= csrf_field() ?>
85+
<input type="hidden" name="action" value="save">
86+
<input type="hidden" name="id" value="<?= e((string) ($editing['id'] ?? '')) ?>">
87+
88+
<h2><?= $editing === null ? 'Add a place' : 'Edit ' . e((string) $editing['name']) ?></h2>
89+
90+
<div class="modal-row">
91+
<div class="field-group">
92+
<label for="placeName">Name</label>
93+
<input type="text" id="placeName" name="name" required maxlength="120"
94+
value="<?= e((string) ($editing['name'] ?? '')) ?>"
95+
placeholder="e.g. Vattakottai Fort">
96+
</div>
97+
<div class="field-group">
98+
<label for="placeCategory">Group</label>
99+
<input type="text" id="placeCategory" name="category" maxlength="60"
100+
value="<?= e((string) ($editing['category'] ?? '')) ?>"
101+
placeholder="e.g. Temples &amp; heritage">
102+
</div>
103+
</div>
104+
105+
<div class="field-group">
106+
<label for="placeBlurb">One or two sentences</label>
107+
<textarea id="placeBlurb" name="blurb" rows="2" maxlength="400"
108+
placeholder="What someone would want to know before driving there."><?= e((string) ($editing['blurb'] ?? '')) ?></textarea>
109+
</div>
110+
111+
<div class="field-group">
112+
<label for="placeMap">Map link</label>
113+
<input type="url" id="placeMap" name="map_url" maxlength="500"
114+
value="<?= e((string) ($editing['map_url'] ?? '')) ?>"
115+
placeholder="https://www.google.com/maps/...">
116+
<p class="field-note">Open the place in Google Maps, press Share, and paste the link.
117+
Visitors get a Directions button that opens it on their phone.</p>
118+
</div>
119+
120+
<div class="modal-row">
121+
<div class="field-group">
122+
<label for="placeOrder">Order</label>
123+
<input type="number" id="placeOrder" name="sort_order" min="0" max="9999"
124+
value="<?= e((string) ($editing['sort_order'] ?? 0)) ?>">
125+
<p class="field-note">Lowest first.</p>
126+
</div>
127+
<div class="field-group">
128+
<label for="placePublished">Shown on the site</label>
129+
<label class="finance-toggle">
130+
<input type="checkbox" id="placePublished" name="published"
131+
<?= ($editing === null || (int) $editing['published'] === 1) ? 'checked' : '' ?>>
132+
Yes
133+
</label>
134+
</div>
135+
</div>
136+
137+
<div class="field-group">
138+
<label for="placePhoto">Photograph</label>
139+
<?php if (!empty($editing['photo_file'])): ?>
140+
<div class="photo-preview">
141+
<img src="<?= e((string) place_photo_url((string) $editing['photo_file'])) ?>" alt="">
142+
</div>
143+
<?php endif; ?>
144+
<input type="file" id="placePhoto" name="photo"
145+
accept="image/jpeg,image/png,image/webp,image/avif">
146+
<p class="field-note">Landscape works best &mdash; it is shown in a wide card. Leave empty
147+
to keep the picture already there. Up to 6&nbsp;MB.</p>
148+
</div>
149+
150+
<div class="modal-actions">
151+
<?php if ($editing !== null): ?>
152+
<a class="btn btn-ghost" href="places.php">Cancel</a>
153+
<?php endif; ?>
154+
<button class="btn btn-primary" type="submit">
155+
<?= $editing === null ? 'Add place' : 'Save changes' ?>
156+
</button>
157+
</div>
158+
</form>
159+
160+
<div class="c-sec">
161+
<h2><?= count($places) ?> place<?= count($places) === 1 ? '' : 's' ?></h2>
162+
163+
<?php if ($places === []): ?>
164+
<div class="empty-state">Nothing yet. Add the first one above.</div>
165+
<?php else: ?>
166+
<div class="place-list">
167+
<?php foreach ($places as $row): ?>
168+
<div class="place-row">
169+
<div class="place-thumb">
170+
<?php if (!empty($row['photo_file'])): ?>
171+
<img src="<?= e((string) place_photo_url((string) $row['photo_file'])) ?>" alt="" loading="lazy">
172+
<?php else: ?>
173+
<span class="brand-slot-empty">No photo</span>
174+
<?php endif; ?>
175+
</div>
176+
<div class="place-body">
177+
<strong><?= e((string) $row['name']) ?></strong>
178+
<?php if ((int) $row['published'] !== 1): ?>
179+
<span class="c-flag">hidden</span>
180+
<?php endif; ?>
181+
<p class="c-note" style="margin:.2rem 0 0"><?= e((string) $row['blurb']) ?></p>
182+
<p class="c-note" style="margin:.2rem 0 0">
183+
<?= e((string) $row['category']) ?> &middot; order <?= (int) $row['sort_order'] ?>
184+
<?= $row['map_url'] !== '' ? ' &middot; has a map link' : ' &middot; no map link' ?>
185+
</p>
186+
</div>
187+
<div class="place-actions">
188+
<a class="btn btn-outline btn-sm" href="places.php?edit=<?= (int) $row['id'] ?>">Edit</a>
189+
<form method="post" onsubmit="return confirm('Remove this place?')">
190+
<?= csrf_field() ?>
191+
<input type="hidden" name="action" value="delete">
192+
<input type="hidden" name="id" value="<?= (int) $row['id'] ?>">
193+
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
194+
</form>
195+
</div>
196+
</div>
197+
<?php endforeach; ?>
198+
</div>
199+
<?php endif; ?>
200+
</div>
201+
202+
</div>
203+
</body>
204+
</html>

‎admin/sql/006_places.sql‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- Places worth driving to, for the tourist places page.
2+
--
3+
-- Content rather than code: the owner adds, edits and reorders these in the
4+
-- panel. A hardcoded list would mean a deploy every time somewhere new opens,
5+
-- and the glass bridge opening is exactly the case that would be missed.
6+
--
7+
-- The map link is a plain Google Maps search URL built from the name. Those
8+
-- keep working when a place is renamed or moved, unlike the long share links
9+
-- with session parameters in them, which rot.
10+
CREATE TABLE IF NOT EXISTS places (
11+
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
12+
name VARCHAR(120) NOT NULL,
13+
category VARCHAR(60) NOT NULL DEFAULT '',
14+
blurb VARCHAR(400) NOT NULL DEFAULT '',
15+
map_url VARCHAR(500) NOT NULL DEFAULT '',
16+
photo_file VARCHAR(160) NULL,
17+
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
18+
published TINYINT(1) NOT NULL DEFAULT 1,
19+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
20+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
21+
PRIMARY KEY (id),
22+
KEY ix_places_order (published, sort_order)
23+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
24+
25+
-- Seeded so the page is useful the moment it exists. Every row is editable and
26+
-- deletable in the panel; none of this is special.
27+
INSERT INTO places (name, category, blurb, map_url, sort_order, published) VALUES
28+
('Vivekananda Rock Memorial', 'Coast & sea', 'A sacred island monument built where Swami Vivekananda meditated. Reached by ferry from the shore.', 'https://www.google.com/maps/search/?api=1&query=Vivekananda%20Rock%20Memorial%2C%20Kanyakumari%2C%20Tamil%20Nadu', 1, 1),
29+
('Thiruvalluvar Statue', 'Coast & sea', 'A 133-foot stone sculpture of the ancient Tamil poet, standing on its own islet beside the rock memorial.', 'https://www.google.com/maps/search/?api=1&query=Thiruvalluvar%20Statue%2C%20Kanyakumari%2C%20Tamil%20Nadu', 2, 1),
30+
('Triveni Sangamam', 'Coast & sea', 'The meeting point of the Arabian Sea, the Bay of Bengal and the Indian Ocean.', 'https://www.google.com/maps/search/?api=1&query=Triveni%20Sangamam%2C%20Kanyakumari%2C%20Tamil%20Nadu', 3, 1),
31+
('Kanyakumari Beach', 'Coast & sea', 'One of the few places in India where you can watch both sunrise and sunset over the sea.', 'https://www.google.com/maps/search/?api=1&query=Kanyakumari%20Beach%2C%20Kanyakumari%2C%20Tamil%20Nadu', 4, 1),
32+
('Kanyakumari Lighthouse', 'Coast & sea', 'Wide panoramic views over the coastline and the offshore monuments.', 'https://www.google.com/maps/search/?api=1&query=Kanyakumari%20Lighthouse%2C%20Kanyakumari%2C%20Tamil%20Nadu', 5, 1),
33+
('Kanyakumari Glass Bridge', 'Viewpoints', 'A recently opened glass walkway over the sea, linking the shore towards the monuments.', 'https://www.google.com/maps/search/?api=1&query=Kanyakumari%20Glass%20Bridge%2C%20Kanyakumari%2C%20Tamil%20Nadu', 6, 1),
34+
('View Tower', 'Viewpoints', 'A helical pedestrian ramp giving an elevated 360-degree view of the coast.', 'https://www.google.com/maps/search/?api=1&query=View%20Tower%2C%20Kanyakumari%2C%20Tamil%20Nadu', 7, 1),
35+
('Bhagavathy Amman Temple', 'Temples & heritage', 'An ancient seaside temple dedicated to the virgin goddess the town is named after.', 'https://www.google.com/maps/search/?api=1&query=Bhagavathy%20Amman%20Temple%2C%20Kanyakumari%2C%20Tamil%20Nadu', 8, 1),
36+
('Mahatma Gandhi Mandapam', 'Temples & heritage', 'A pink memorial built on the spot where Gandhi''s ashes were kept before immersion.', 'https://www.google.com/maps/search/?api=1&query=Mahatma%20Gandhi%20Mandapam%2C%20Kanyakumari%2C%20Tamil%20Nadu', 9, 1),
37+
('Our Lady of Ransom Church', 'Temples & heritage', 'A white Gothic church near the main beach, lit up at night.', 'https://www.google.com/maps/search/?api=1&query=Our%20Lady%20of%20Ransom%20Church%2C%20Kanyakumari%2C%20Tamil%20Nadu', 10, 1),
38+
('Vattakottai Fort', 'Temples & heritage', 'An 18th-century granite fort on the shore, with ramparts you can walk.', 'https://www.google.com/maps/search/?api=1&query=Vattakottai%20Fort%2C%20Kanyakumari%2C%20Tamil%20Nadu', 11, 1),
39+
('Suchindram Thanumalayan Temple', 'Temples & heritage', 'Known for its musical pillars and a Hanuman carved from a single rock.', 'https://www.google.com/maps/search/?api=1&query=Suchindram%20Thanumalayan%20Temple%2C%20Kanyakumari%2C%20Tamil%20Nadu', 12, 1),
40+
('Padmanabhapuram Palace', 'Temples & heritage', 'A large wooden palace near Thuckalay, famous for its carved ceilings and cool stone floors.', 'https://www.google.com/maps/search/?api=1&query=Padmanabhapuram%20Palace%2C%20Kanyakumari%2C%20Tamil%20Nadu', 13, 1),
41+
('Thirparappu Waterfalls', 'Waterfalls & hills', 'A 50-foot waterfall with a bathing pool below, at its best after the rains.', 'https://www.google.com/maps/search/?api=1&query=Thirparappu%20Waterfalls%2C%20Kanyakumari%2C%20Tamil%20Nadu', 14, 1),
42+
('Mathoor Hanging Bridge', 'Waterfalls & hills', 'One of the tallest and longest aqueduct bridges in the country, crossing a wooded valley.', 'https://www.google.com/maps/search/?api=1&query=Mathoor%20Hanging%20Bridge%2C%20Kanyakumari%2C%20Tamil%20Nadu', 15, 1),
43+
('Olakaruvi Waterfalls', 'Waterfalls & hills', 'A short forest trek to clear hill streams falling over the rocks.', 'https://www.google.com/maps/search/?api=1&query=Olakaruvi%20Waterfalls%2C%20Kanyakumari%2C%20Tamil%20Nadu', 16, 1),
44+
('Kovalam Beach', 'Quieter beaches', 'An arc of golden sand a short drive from town, usually far quieter than the main beach.', 'https://www.google.com/maps/search/?api=1&query=Kovalam%20Beach%2C%20Kanyakumari%2C%20Tamil%20Nadu', 17, 1),
45+
('Sanguthurai Beach', 'Quieter beaches', 'A clean, calm beach with white sand, known locally for its kite flying.', 'https://www.google.com/maps/search/?api=1&query=Sanguthurai%20Beach%2C%20Kanyakumari%2C%20Tamil%20Nadu', 18, 1),
46+
('Muttom Beach', 'Quieter beaches', 'Dramatic rocky shore further up the coast, with a historic lighthouse above it.', 'https://www.google.com/maps/search/?api=1&query=Muttom%20Beach%2C%20Kanyakumari%2C%20Tamil%20Nadu', 19, 1);

0 commit comments

Comments
 (0)