Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48387223bb | ||
|
|
e1c2d4076d | ||
|
|
1c7765e1cd | ||
|
|
72bfa75c68 |
249
admin.php
Normal file
249
admin.php
Normal file
@ -0,0 +1,249 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/urban_hikes.php';
|
||||
|
||||
$storage = urban_hikes_storage();
|
||||
$routeId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$editingRoute = $routeId > 0 ? urban_hikes_find($routeId) : null;
|
||||
$isEditMode = $editingRoute !== null;
|
||||
$missingRoute = $routeId > 0 && !$editingRoute;
|
||||
$errors = [];
|
||||
$formData = [
|
||||
'city' => '',
|
||||
'title' => '',
|
||||
'summary' => '',
|
||||
'distance_km' => '',
|
||||
'duration_hours' => '',
|
||||
'difficulty' => 'Moderate',
|
||||
'neighborhood' => '',
|
||||
'start_point' => '',
|
||||
'highlights' => '',
|
||||
'map_url' => '',
|
||||
'best_for' => '',
|
||||
];
|
||||
|
||||
if ($editingRoute) {
|
||||
$formData = array_merge($formData, $editingRoute);
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$postedId = isset($_POST['route_id']) ? (int)$_POST['route_id'] : 0;
|
||||
if ($postedId > 0) {
|
||||
$result = urban_hikes_update($postedId, $_POST);
|
||||
$routeId = $postedId;
|
||||
$isEditMode = true;
|
||||
} else {
|
||||
$result = urban_hikes_create($_POST);
|
||||
$routeId = 0;
|
||||
$isEditMode = false;
|
||||
}
|
||||
|
||||
if (!empty($result['success'])) {
|
||||
urban_hikes_set_flash('success', $postedId > 0 ? 'Route updated successfully.' : 'Route saved and published to the directory.');
|
||||
header('Location: route.php?id=' . (int)$result['id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$errors = $result['errors'] ?? [];
|
||||
$formData = array_merge($formData, $result['input'] ?? []);
|
||||
$editingRoute = $routeId > 0 ? urban_hikes_find($routeId) : null;
|
||||
$missingRoute = $routeId > 0 && !$editingRoute;
|
||||
}
|
||||
|
||||
if ($missingRoute) {
|
||||
http_response_code(404);
|
||||
}
|
||||
|
||||
$latestRoutes = urban_hikes_latest(10);
|
||||
$pageTitle = ($isEditMode ? 'Edit route' : 'Add a route') . ' | ' . urban_hikes_project_name();
|
||||
$pageDescription = $isEditMode
|
||||
? 'Update an existing urban hiking route and republish the latest details.'
|
||||
: 'Add an urban hiking route with city, difficulty, distance, highlights, and map link.';
|
||||
$submitLabel = $isEditMode ? 'Save changes' : 'Publish route';
|
||||
|
||||
urban_hikes_render_head($pageTitle, $pageDescription, 'noindex, follow');
|
||||
urban_hikes_render_nav('admin');
|
||||
?>
|
||||
<main>
|
||||
<section class="section-shell border-bottom">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5">
|
||||
<?php if ($missingRoute): ?>
|
||||
<div class="panel-card empty-state text-center">
|
||||
<span class="eyebrow">Route missing</span>
|
||||
<h1 class="section-title mt-2 mb-2">That route could not be opened for editing.</h1>
|
||||
<p class="text-muted mb-4">Try returning to the directory and opening a valid route first.</p>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center">
|
||||
<a class="btn btn-dark" href="index.php#results">Back to directory</a>
|
||||
<a class="btn btn-outline-secondary" href="admin.php">Create a new route instead</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-7">
|
||||
<div class="panel-card">
|
||||
<span class="eyebrow">Content admin</span>
|
||||
<h1 class="section-title mt-2 mb-2"><?= $isEditMode ? 'Edit urban route' : 'Add a new urban route' ?></h1>
|
||||
<p class="text-muted mb-4">
|
||||
<?= $isEditMode
|
||||
? 'Update the route details below. After saving, the public directory and route page will immediately show the latest version.'
|
||||
: 'This admin screen keeps the workflow small: add a route once, then browse it in the public directory immediately.' ?>
|
||||
</p>
|
||||
|
||||
<?php if (!$storage['ready']): ?>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
Database saving is unavailable right now, so changes cannot be published yet.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($errors['storage'])): ?>
|
||||
<div class="alert alert-warning" role="alert"><?= htmlspecialchars($errors['storage']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="admin.php<?= $isEditMode ? '?id=' . (int)$routeId : '' ?>" class="row g-3" novalidate>
|
||||
<?php if ($isEditMode): ?>
|
||||
<input type="hidden" name="route_id" value="<?= (int)$routeId ?>" />
|
||||
<?php endif; ?>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="city">City</label>
|
||||
<input class="form-control <?= isset($errors['city']) ? 'is-invalid' : '' ?>" type="text" id="city" name="city" value="<?= htmlspecialchars((string)$formData['city']) ?>" placeholder="Berlin" required />
|
||||
<?php if (isset($errors['city'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['city']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="difficulty">Difficulty</label>
|
||||
<select class="form-select <?= isset($errors['difficulty']) ? 'is-invalid' : '' ?>" id="difficulty" name="difficulty">
|
||||
<?php foreach (['Easy', 'Moderate', 'Challenging'] as $level): ?>
|
||||
<option value="<?= htmlspecialchars($level) ?>" <?= (string)$formData['difficulty'] === $level ? 'selected' : '' ?>><?= htmlspecialchars($level) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($errors['difficulty'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['difficulty']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="title">Route title</label>
|
||||
<input class="form-control <?= isset($errors['title']) ? 'is-invalid' : '' ?>" type="text" id="title" name="title" value="<?= htmlspecialchars((string)$formData['title']) ?>" placeholder="Hilltop parks to market streets walk" required />
|
||||
<?php if (isset($errors['title'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['title']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="distance_km">Distance (km)</label>
|
||||
<input class="form-control <?= isset($errors['distance_km']) ? 'is-invalid' : '' ?>" type="number" min="0.1" step="0.1" id="distance_km" name="distance_km" value="<?= htmlspecialchars((string)$formData['distance_km']) ?>" required />
|
||||
<?php if (isset($errors['distance_km'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['distance_km']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="duration_hours">Duration (hours)</label>
|
||||
<input class="form-control <?= isset($errors['duration_hours']) ? 'is-invalid' : '' ?>" type="number" min="0.1" step="0.1" id="duration_hours" name="duration_hours" value="<?= htmlspecialchars((string)$formData['duration_hours']) ?>" required />
|
||||
<?php if (isset($errors['duration_hours'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['duration_hours']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="neighborhood">Neighborhood / area</label>
|
||||
<input class="form-control <?= isset($errors['neighborhood']) ? 'is-invalid' : '' ?>" type="text" id="neighborhood" name="neighborhood" value="<?= htmlspecialchars((string)$formData['neighborhood']) ?>" placeholder="Waterfront district" required />
|
||||
<?php if (isset($errors['neighborhood'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['neighborhood']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="start_point">Start point</label>
|
||||
<input class="form-control <?= isset($errors['start_point']) ? 'is-invalid' : '' ?>" type="text" id="start_point" name="start_point" value="<?= htmlspecialchars((string)$formData['start_point']) ?>" placeholder="Central Station" required />
|
||||
<?php if (isset($errors['start_point'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['start_point']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label d-flex justify-content-between align-items-center" for="summary">
|
||||
<span>Summary</span>
|
||||
<span class="field-count" id="summaryCount">0 chars</span>
|
||||
</label>
|
||||
<textarea class="form-control <?= isset($errors['summary']) ? 'is-invalid' : '' ?>" id="summary" name="summary" rows="3" placeholder="What makes this route worth a half day?" data-count-target="summaryCount"><?= htmlspecialchars((string)$formData['summary']) ?></textarea>
|
||||
<?php if (isset($errors['summary'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['summary']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label d-flex justify-content-between align-items-center" for="highlights">
|
||||
<span>Highlights</span>
|
||||
<span class="field-count" id="highlightsCount">0 chars</span>
|
||||
</label>
|
||||
<textarea class="form-control <?= isset($errors['highlights']) ? 'is-invalid' : '' ?>" id="highlights" name="highlights" rows="4" placeholder="One highlight per line" data-count-target="highlightsCount"><?= htmlspecialchars((string)$formData['highlights']) ?></textarea>
|
||||
<div class="form-text">Enter one stop or viewpoint per line.</div>
|
||||
<?php if (isset($errors['highlights'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['highlights']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label" for="map_url">Map link</label>
|
||||
<input class="form-control <?= isset($errors['map_url']) ? 'is-invalid' : '' ?>" type="url" id="map_url" name="map_url" value="<?= htmlspecialchars((string)$formData['map_url']) ?>" placeholder="https://maps.google.com/..." required />
|
||||
<?php if (isset($errors['map_url'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['map_url']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="best_for">Best for</label>
|
||||
<input class="form-control <?= isset($errors['best_for']) ? 'is-invalid' : '' ?>" type="text" id="best_for" name="best_for" value="<?= htmlspecialchars((string)$formData['best_for']) ?>" placeholder="Early morning walkers" required />
|
||||
<?php if (isset($errors['best_for'])): ?><div class="invalid-feedback"><?= htmlspecialchars($errors['best_for']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2 pt-2">
|
||||
<button class="btn btn-dark" type="submit" <?= !$storage['ready'] ? 'disabled' : '' ?>><?= htmlspecialchars($submitLabel) ?></button>
|
||||
<?php if ($isEditMode): ?>
|
||||
<a class="btn btn-outline-secondary" href="route.php?id=<?= (int)$routeId ?>">Cancel</a>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-outline-secondary" href="index.php#results">Back to directory</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="panel-card mb-3">
|
||||
<span class="eyebrow"><?= $isEditMode ? 'Editing' : 'Workflow' ?></span>
|
||||
<h2 class="section-title h5 mt-2"><?= $isEditMode ? 'What changes update immediately' : 'What this slice covers' ?></h2>
|
||||
<ul class="compact-list mb-0 mt-3">
|
||||
<?php if ($isEditMode): ?>
|
||||
<li>Update route content without touching the database manually</li>
|
||||
<li>Keep validation and safe prepared statements in one place</li>
|
||||
<li>Return to the public route page after saving</li>
|
||||
<li>Refresh the route everywhere it appears in the directory</li>
|
||||
<?php else: ?>
|
||||
<li>Add a route with the key planning fields</li>
|
||||
<li>Store it in MariaDB with prepared statements</li>
|
||||
<li>Redirect to a detail page with a confirmation toast</li>
|
||||
<li>See it in the public directory immediately</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<span class="eyebrow">Manage routes</span>
|
||||
<h2 class="section-title h5 mt-2 mb-0">Recent routes</h2>
|
||||
</div>
|
||||
<a class="small text-decoration-none" href="index.php#results">Open directory</a>
|
||||
</div>
|
||||
<?php if (!$latestRoutes): ?>
|
||||
<p class="text-muted mb-0">No routes published yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle admin-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>City</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($latestRoutes as $route): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= htmlspecialchars((string)$route['title']) ?></div>
|
||||
<div class="text-muted small"><?= htmlspecialchars((string)$route['difficulty']) ?> · <?= htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km</div>
|
||||
</td>
|
||||
<td><a class="text-decoration-none" href="city.php?city=<?= rawurlencode((string)$route['city']) ?>"><?= htmlspecialchars((string)$route['city']) ?></a></td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-flex gap-2 flex-wrap justify-content-end">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="admin.php?id=<?= (int)$route['id'] ?>">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="route.php?id=<?= (int)$route['id'] ?>">Open</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php urban_hikes_render_footer(); ?>
|
||||
@ -1,403 +1,443 @@
|
||||
:root {
|
||||
--bg: #f4f4f2;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #fafaf9;
|
||||
--text: #171717;
|
||||
--muted: #5f6368;
|
||||
--line: #deded8;
|
||||
--line-strong: #cfcfc8;
|
||||
--accent: #111827;
|
||||
--accent-soft: #eef1f4;
|
||||
--success: #1f5132;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
--shadow-md: 0 10px 24px rgba(17, 24, 39, 0.05);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
color: #212529;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.site-footer {
|
||||
background: rgba(244, 244, 242, 0.96);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding-block: 0.8rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 85vh;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
.nav-link {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message.visitor {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.bot {
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-input-area form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area button:hover {
|
||||
background: #000;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Background Animations */
|
||||
.bg-animations {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
background: rgba(238, 119, 82, 0.4);
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
background: rgba(35, 166, 213, 0.4);
|
||||
animation-delay: -7s;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
background: rgba(231, 60, 126, 0.3);
|
||||
animation-delay: -14s;
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.header-link {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Admin Styles */
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 3rem auto;
|
||||
padding: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.admin-container h1 {
|
||||
margin-top: 0;
|
||||
color: #212529;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
.hero-shell,
|
||||
.section-shell {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.py-lg-6 {
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.display-title,
|
||||
.detail-title {
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
font-weight: 700;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.lead-copy {
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.panel-card,
|
||||
.detail-aside-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
margin-bottom: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table td {
|
||||
.metric-value {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compact-list,
|
||||
.highlights-list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.compact-list li,
|
||||
.highlights-list li {
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.highlights-list.large li {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.form-label,
|
||||
.field-count,
|
||||
.form-text,
|
||||
.text-muted {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
min-height: 2.85rem;
|
||||
border-color: var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.table tr td:first-child { border-radius: 12px 0 0 12px; }
|
||||
.table tr td:last-child { border-radius: 0 12px 12px 0; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
textarea.form-control {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
.btn:focus,
|
||||
.nav-link:focus,
|
||||
.city-card:focus,
|
||||
.subroute-link:focus,
|
||||
.route-title a:focus {
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 0.2rem rgba(148, 163, 184, 0.2) !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 9px;
|
||||
padding: 0.78rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-dark:hover,
|
||||
.btn-dark:focus {
|
||||
background: #000;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover,
|
||||
.btn-outline-secondary:focus {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.route-card {
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.route-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: #c8ccd2;
|
||||
}
|
||||
|
||||
.route-title {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.route-title a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.route-meta,
|
||||
.detail-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.route-meta span,
|
||||
.metric-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-chip {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.metric-chip strong {
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.metric-chip-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.route-actions {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: left;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.city-card {
|
||||
color: inherit;
|
||||
transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.city-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #c8ccd2;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.city-name,
|
||||
.city-count {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.city-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.city-count {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
.sticky-filter-card {
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
.detail-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
.detail-list dt {
|
||||
margin-bottom: 0.2rem;
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
.detail-list dd {
|
||||
margin-bottom: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
.subroute-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #212529;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
.subroute-link span {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #0088cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
.admin-table th {
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 0.5rem;
|
||||
.admin-table td,
|
||||
.admin-table th {
|
||||
border-color: var(--line);
|
||||
padding-block: 0.8rem;
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
.app-toast {
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
.badge.text-bg-light {
|
||||
background: var(--surface-alt) !important;
|
||||
color: var(--text) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.breadcrumb {
|
||||
--bs-breadcrumb-divider-color: var(--muted);
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.breadcrumb a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.site-footer a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}
|
||||
.site-footer a:hover,
|
||||
.breadcrumb a:hover,
|
||||
.route-title a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.display-title,
|
||||
.detail-title {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sticky-filter-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.route-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.panel-card,
|
||||
.detail-aside-card {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.route-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-metrics {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const toastEl = document.getElementById('appToast');
|
||||
if (toastEl && window.bootstrap) {
|
||||
const toast = new window.bootstrap.Toast(toastEl, { delay: 4200 });
|
||||
toast.show();
|
||||
}
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
};
|
||||
if (window.location.hash === '#results') {
|
||||
document.getElementById('results')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const message = chatInput.value.trim();
|
||||
if (!message) return;
|
||||
document.querySelectorAll('[data-autosubmit="change"]').forEach((field) => {
|
||||
field.addEventListener('change', () => {
|
||||
if (field.form) {
|
||||
field.form.requestSubmit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
appendMessage(message, 'visitor');
|
||||
chatInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('api/chat.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
document.querySelectorAll('[data-count-target]').forEach((field) => {
|
||||
const counter = document.getElementById(field.dataset.countTarget || '');
|
||||
if (!counter) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
counter.textContent = `${field.value.trim().length} chars`;
|
||||
};
|
||||
update();
|
||||
field.addEventListener('input', update);
|
||||
});
|
||||
});
|
||||
|
||||
BIN
assets/pasted-20260416-100807-511c0cf1.png
Normal file
BIN
assets/pasted-20260416-100807-511c0cf1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 551 KiB |
BIN
assets/vm-shot-2026-04-15T16-06-39-701Z.jpg
Normal file
BIN
assets/vm-shot-2026-04-15T16-06-39-701Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
191
city.php
Normal file
191
city.php
Normal file
@ -0,0 +1,191 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/urban_hikes.php';
|
||||
|
||||
$city = trim((string)($_GET['city'] ?? ''));
|
||||
$routes = $city !== '' ? urban_hikes_find_by_city($city) : [];
|
||||
$cityStats = $city !== '' ? urban_hikes_city_stats($city) : null;
|
||||
$allCities = urban_hikes_cities();
|
||||
$otherCities = array_values(array_filter($allCities, static function (array $item) use ($city): bool {
|
||||
return (string)($item['city'] ?? '') !== $city;
|
||||
}));
|
||||
|
||||
if (!$cityStats) {
|
||||
http_response_code(404);
|
||||
}
|
||||
|
||||
$routeCount = $cityStats ? (int)$cityStats['route_count'] : 0;
|
||||
$avgDistance = $cityStats ? number_format((float)$cityStats['avg_distance'], 1) : '0.0';
|
||||
$avgDuration = $cityStats ? number_format((float)$cityStats['avg_duration'], 1) : '0.0';
|
||||
$areaCount = $cityStats ? (int)$cityStats['area_count'] : 0;
|
||||
$pageTitle = $cityStats
|
||||
? 'Urban hiking in ' . $cityStats['city'] . ' | ' . urban_hikes_project_name()
|
||||
: 'City not found | ' . urban_hikes_project_name();
|
||||
$pageDescription = $cityStats
|
||||
? 'Browse ' . $routeCount . ' urban hiking route' . ($routeCount === 1 ? '' : 's') . ' in ' . $cityStats['city'] . ', compare difficulty, distance, and route highlights, and plan a full walking day.'
|
||||
: 'The requested city guide could not be found.';
|
||||
$shortestRoute = $routes ? $routes[0] : null;
|
||||
$longestRoute = $routes ? $routes[count($routes) - 1] : null;
|
||||
|
||||
urban_hikes_render_head($pageTitle, $pageDescription, $cityStats ? 'index, follow' : 'noindex, nofollow');
|
||||
urban_hikes_render_nav('cities');
|
||||
?>
|
||||
<main>
|
||||
<section class="hero-shell border-bottom">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5">
|
||||
<?php if (!$cityStats): ?>
|
||||
<div class="panel-card empty-state text-center">
|
||||
<span class="eyebrow">City missing</span>
|
||||
<h1 class="section-title mt-2 mb-2">We could not find that city guide.</h1>
|
||||
<p class="text-muted mb-4">Try opening a city from the directory instead.</p>
|
||||
<a class="btn btn-dark" href="index.php#cities">Back to cities</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small mb-0">
|
||||
<li class="breadcrumb-item"><a href="index.php">Directory</a></li>
|
||||
<li class="breadcrumb-item"><a href="index.php#cities">Cities</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?= htmlspecialchars((string)$cityStats['city']) ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-4 align-items-end">
|
||||
<div class="col-lg-7">
|
||||
<span class="eyebrow">City guide</span>
|
||||
<h1 class="display-title mt-3 mb-3">Urban hiking in <?= htmlspecialchars((string)$cityStats['city']) ?></h1>
|
||||
<p class="lead-copy mb-4">
|
||||
Use this city page as your quick planner: compare route length, difficulty, starting point, and route highlights before heading out.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-dark px-4" href="#city-routes">Browse routes</a>
|
||||
<a class="btn btn-outline-secondary px-4" href="index.php?city=<?= rawurlencode((string)$cityStats['city']) ?>#results">Open filtered directory</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="panel-card stats-panel h-100">
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-3 col-lg-6">
|
||||
<p class="metric-label">Routes</p>
|
||||
<p class="metric-value"><?= $routeCount ?></p>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-lg-6">
|
||||
<p class="metric-label">Avg km</p>
|
||||
<p class="metric-value"><?= htmlspecialchars($avgDistance) ?></p>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-lg-6">
|
||||
<p class="metric-label">Avg hours</p>
|
||||
<p class="metric-value"><?= htmlspecialchars($avgDuration) ?></p>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-lg-6">
|
||||
<p class="metric-label">Areas</p>
|
||||
<p class="metric-value"><?= $areaCount ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-4"></div>
|
||||
<ul class="compact-list mb-0">
|
||||
<li><?= (int)$cityStats['easy_count'] ?> easy · <?= (int)$cityStats['moderate_count'] ?> moderate · <?= (int)$cityStats['challenging_count'] ?> challenging</li>
|
||||
<?php if ($shortestRoute): ?>
|
||||
<li>Shortest option: <?= htmlspecialchars((string)$shortestRoute['title']) ?> at <?= htmlspecialchars(number_format((float)$shortestRoute['distance_km'], 1)) ?> km</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($longestRoute && $longestRoute !== $shortestRoute): ?>
|
||||
<li>Longest option: <?= htmlspecialchars((string)$longestRoute['title']) ?> at <?= htmlspecialchars(number_format((float)$longestRoute['distance_km'], 1)) ?> km</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($cityStats): ?>
|
||||
<section class="section-shell border-bottom" id="city-routes">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-3 mb-3">
|
||||
<div>
|
||||
<span class="eyebrow"><?= htmlspecialchars((string)$cityStats['city']) ?> routes</span>
|
||||
<h2 class="section-title mb-1 mt-2">Pick the walk that fits your day.</h2>
|
||||
<p class="text-muted mb-0">Every route below links to a full detail page and an external map.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm align-self-start align-self-md-auto" href="admin.php">Add a route</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<?php foreach ($routes as $route): ?>
|
||||
<?php $highlights = array_slice(urban_hikes_highlight_items((string)$route['highlights']), 0, 3); ?>
|
||||
<div class="col-12">
|
||||
<article class="panel-card route-card h-100">
|
||||
<div class="d-flex flex-column flex-lg-row gap-3 justify-content-between">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars((string)$route['difficulty']) ?></span>
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars((string)$route['neighborhood']) ?></span>
|
||||
<span class="badge text-bg-light border">Start: <?= htmlspecialchars((string)$route['start_point']) ?></span>
|
||||
</div>
|
||||
<h2 class="route-title mb-2"><a href="route.php?id=<?= (int)$route['id'] ?>"><?= htmlspecialchars((string)$route['title']) ?></a></h2>
|
||||
<p class="text-muted mb-3"><?= htmlspecialchars((string)$route['summary']) ?></p>
|
||||
<div class="route-meta mb-3">
|
||||
<span><?= htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km</span>
|
||||
<span><?= htmlspecialchars(number_format((float)$route['duration_hours'], 1)) ?> hr</span>
|
||||
<span><?= htmlspecialchars((string)$route['best_for']) ?></span>
|
||||
</div>
|
||||
<?php if ($highlights): ?>
|
||||
<ul class="highlights-list mb-0">
|
||||
<?php foreach ($highlights as $item): ?>
|
||||
<li><?= htmlspecialchars($item) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="route-actions d-flex flex-lg-column justify-content-between gap-2">
|
||||
<a class="btn btn-dark" href="route.php?id=<?= (int)$route['id'] ?>">View details</a>
|
||||
<a class="btn btn-outline-secondary" href="admin.php?id=<?= (int)$route['id'] ?>">Edit route</a>
|
||||
<a class="btn btn-outline-secondary" href="<?= htmlspecialchars((string)$route['map_url']) ?>" target="_blank" rel="noopener">Open map</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="panel-card mb-3">
|
||||
<span class="eyebrow">Planning notes</span>
|
||||
<h2 class="section-title h5 mt-2">How to use this city page</h2>
|
||||
<ul class="compact-list mb-0 mt-3">
|
||||
<li>Start with distance and duration to match your available time.</li>
|
||||
<li>Open a route map only after the route summary looks right.</li>
|
||||
<li>Use the filtered directory view if you want broader search controls.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<span class="eyebrow">Explore more</span>
|
||||
<h2 class="section-title h5 mt-2 mb-0">Other city guides</h2>
|
||||
</div>
|
||||
<a class="small text-decoration-none" href="index.php#cities">All cities</a>
|
||||
</div>
|
||||
<?php if (!$otherCities): ?>
|
||||
<p class="text-muted mb-0">Add another city route to expand the guide.</p>
|
||||
<?php else: ?>
|
||||
<div class="vstack gap-3">
|
||||
<?php foreach (array_slice($otherCities, 0, 5) as $cityRow): ?>
|
||||
<a class="subroute-link" href="city.php?city=<?= rawurlencode((string)$cityRow['city']) ?>">
|
||||
<strong><?= htmlspecialchars((string)$cityRow['city']) ?></strong>
|
||||
<span><?= (int)$cityRow['route_count'] ?> route<?= (int)$cityRow['route_count'] === 1 ? '' : 's' ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
<?php urban_hikes_render_footer(); ?>
|
||||
351
index.php
351
index.php
@ -1,150 +1,213 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
require_once __DIR__ . '/urban_hikes.php';
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$filters = urban_hikes_fetch_filters();
|
||||
$routes = urban_hikes_search($filters);
|
||||
$cities = urban_hikes_cities();
|
||||
$stats = urban_hikes_stats();
|
||||
$flash = urban_hikes_get_flash();
|
||||
$storage = urban_hikes_storage();
|
||||
|
||||
$pageTitle = urban_hikes_project_name() . ' | Urban hiking routes directory';
|
||||
$pageDescription = 'Find urban hiking routes in major cities by distance, time, and difficulty. Compare highlights, browse by city, and plan a full day on foot.';
|
||||
|
||||
urban_hikes_render_head($pageTitle, $pageDescription);
|
||||
urban_hikes_render_nav('directory');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<main>
|
||||
<section class="hero-shell border-bottom">
|
||||
<div class="container-lg px-3 px-lg-4 py-5 py-lg-6">
|
||||
<div class="row g-4 align-items-end">
|
||||
<div class="col-lg-7">
|
||||
<span class="eyebrow">Urban hiking directory</span>
|
||||
<h1 class="display-title mt-3 mb-3">Big-city routes for locals, visitors, and long walking days.</h1>
|
||||
<p class="lead-copy mb-4">Search curated city hikes by place, distance, and difficulty. Every route gives you the basics fast: how long it takes, where it starts, and what makes it worth the walk.</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-dark px-4" href="#results">Browse routes</a>
|
||||
<a class="btn btn-outline-secondary px-4" href="admin.php">Add a route</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="panel-card stats-panel h-100">
|
||||
<div class="row g-3">
|
||||
<div class="col-4">
|
||||
<p class="metric-label">Routes</p>
|
||||
<p class="metric-value"><?= (int)$stats['routes'] ?></p>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<p class="metric-label">Cities</p>
|
||||
<p class="metric-value"><?= (int)$stats['cities'] ?></p>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<p class="metric-label">Avg km</p>
|
||||
<p class="metric-value"><?= htmlspecialchars(number_format((float)$stats['avg_distance'], 1)) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-4"></div>
|
||||
<p class="small text-muted mb-2">Useful when you want:</p>
|
||||
<ul class="compact-list mb-0">
|
||||
<li>A scenic 2–3 hour city plan</li>
|
||||
<li>A route that matches your walking energy</li>
|
||||
<li>Quick route comparison before you leave the hotel</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</section>
|
||||
|
||||
<section class="section-shell border-bottom" id="results">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5">
|
||||
<?php if ($flash): ?>
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<div class="toast app-toast text-bg-dark border-0" id="appToast" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body"><?= htmlspecialchars($flash['message']) ?></div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$storage['ready']): ?>
|
||||
<div class="alert alert-warning mb-4" role="alert">
|
||||
Route browsing is visible, but saving new routes is temporarily unavailable because the database connection failed.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-xl-4">
|
||||
<div class="panel-card sticky-filter-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<span class="eyebrow">Find a route</span>
|
||||
<h2 class="section-title mb-0 mt-2">Search filters</h2>
|
||||
</div>
|
||||
<a class="small text-decoration-none" href="index.php#results">Reset</a>
|
||||
</div>
|
||||
<form method="get" action="index.php#results" class="row g-3" data-results-form>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="q">Keyword</label>
|
||||
<input class="form-control" type="text" id="q" name="q" value="<?= htmlspecialchars($filters['q']) ?>" placeholder="Canal, skyline, park, stairs" />
|
||||
</div>
|
||||
<div class="col-md-6 col-xl-12">
|
||||
<label class="form-label" for="city">City</label>
|
||||
<select class="form-select" id="city" name="city" data-autosubmit="change">
|
||||
<option value="">All cities</option>
|
||||
<?php foreach ($cities as $cityRow): ?>
|
||||
<option value="<?= htmlspecialchars($cityRow['city']) ?>" <?= $filters['city'] === $cityRow['city'] ? 'selected' : '' ?>><?= htmlspecialchars($cityRow['city']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 col-xl-12">
|
||||
<label class="form-label" for="difficulty">Difficulty</label>
|
||||
<select class="form-select" id="difficulty" name="difficulty" data-autosubmit="change">
|
||||
<option value="">Any level</option>
|
||||
<?php foreach (['Easy', 'Moderate', 'Challenging'] as $level): ?>
|
||||
<option value="<?= htmlspecialchars($level) ?>" <?= $filters['difficulty'] === $level ? 'selected' : '' ?>><?= htmlspecialchars($level) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="max_distance">Maximum distance</label>
|
||||
<select class="form-select" id="max_distance" name="max_distance" data-autosubmit="change">
|
||||
<option value="">Any length</option>
|
||||
<?php foreach ([5, 8, 10, 12] as $distance): ?>
|
||||
<option value="<?= $distance ?>" <?= (float)$filters['max_distance'] === (float)$distance ? 'selected' : '' ?>>Up to <?= $distance ?> km</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-dark flex-grow-1" type="submit">Apply filters</button>
|
||||
<a class="btn btn-outline-secondary" href="index.php#results">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-8">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-3 mb-3">
|
||||
<div>
|
||||
<span class="eyebrow">Directory</span>
|
||||
<h2 class="section-title mb-1 mt-2"><?= count($routes) ?> route<?= count($routes) === 1 ? '' : 's' ?> matched</h2>
|
||||
<p class="text-muted mb-0">Compact route cards with the essentials first: timing, effort, area, and highlights.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm align-self-start align-self-md-auto" href="admin.php">Suggest a new route</a>
|
||||
</div>
|
||||
|
||||
<?php if (!$routes): ?>
|
||||
<div class="panel-card empty-state">
|
||||
<span class="eyebrow">No results</span>
|
||||
<h3 class="h5 mt-2">No routes match these filters yet.</h3>
|
||||
<p class="text-muted mb-4">Try widening the distance or removing the city filter. You can also add your own route to start a new city collection.</p>
|
||||
<a class="btn btn-dark" href="admin.php">Add a route</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($routes as $route): ?>
|
||||
<?php $highlights = array_slice(urban_hikes_highlight_items((string)$route['highlights']), 0, 3); ?>
|
||||
<div class="col-12">
|
||||
<article class="panel-card route-card h-100">
|
||||
<div class="d-flex flex-column flex-lg-row gap-3 justify-content-between">
|
||||
<div class="route-image-placeholder me-lg-4" style="width: 200px; height: 150px; background: #deded8; border-radius: var(--radius-md);"></div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars($route['city']) ?></span>
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars($route['difficulty']) ?></span>
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars($route['neighborhood']) ?></span>
|
||||
</div>
|
||||
<h3 class="route-title mb-2"><a href="route.php?id=<?= (int)$route['id'] ?>"><?= htmlspecialchars($route['title']) ?></a></h3>
|
||||
<p class="text-muted mb-3"><?= htmlspecialchars($route['summary']) ?></p>
|
||||
<div class="route-meta mb-3">
|
||||
<span><?= htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km</span>
|
||||
<span><?= htmlspecialchars(number_format((float)$route['duration_hours'], 1)) ?> hr</span>
|
||||
<span>Start: <?= htmlspecialchars($route['start_point']) ?></span>
|
||||
</div>
|
||||
<?php if ($highlights): ?>
|
||||
<ul class="highlights-list mb-0">
|
||||
<?php foreach ($highlights as $item): ?>
|
||||
<li><?= htmlspecialchars($item) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="route-actions d-flex flex-lg-column justify-content-between gap-2">
|
||||
<a class="btn btn-dark" href="route.php?id=<?= (int)$route['id'] ?>">View details</a>
|
||||
<a class="btn btn-outline-secondary" href="city.php?city=<?= rawurlencode((string)$route['city']) ?>">City guide</a>
|
||||
<a class="btn btn-outline-secondary" href="<?= htmlspecialchars($route['map_url']) ?>" target="_blank" rel="noopener">Open map</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-shell" id="cities">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-end gap-3 mb-3">
|
||||
<div>
|
||||
<span class="eyebrow">Cities</span>
|
||||
<h2 class="section-title mb-1 mt-2">Start from a city, then narrow it down.</h2>
|
||||
<p class="text-muted mb-0">Fast jumping-off points for the places already in the directory.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($cities as $cityRow): ?>
|
||||
<div class="col-6 col-md-4 col-xl-2">
|
||||
<a class="city-card panel-card h-100 d-block text-decoration-none" href="city.php?city=<?= rawurlencode((string)$cityRow['city']) ?>">
|
||||
<span class="city-name"><?= htmlspecialchars($cityRow['city']) ?></span>
|
||||
<span class="city-count"><?= (int)$cityRow['route_count'] ?> route<?= (int)$cityRow['route_count'] === 1 ? '' : 's' ?></span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php urban_hikes_render_footer(); ?>
|
||||
|
||||
136
route.php
Normal file
136
route.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/urban_hikes.php';
|
||||
|
||||
$routeId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$route = $routeId > 0 ? urban_hikes_find($routeId) : null;
|
||||
$flash = urban_hikes_get_flash();
|
||||
|
||||
if (!$route) {
|
||||
http_response_code(404);
|
||||
}
|
||||
|
||||
$pageTitle = $route ? $route['title'] . ' | ' . urban_hikes_project_name() : 'Route not found | ' . urban_hikes_project_name();
|
||||
$pageDescription = $route ? $route['summary'] : 'The requested urban hiking route could not be found.';
|
||||
$related = $route ? urban_hikes_related((string)$route['city'], (int)$route['id']) : [];
|
||||
|
||||
urban_hikes_render_head($pageTitle, $pageDescription, $route ? 'index, follow' : 'noindex, nofollow');
|
||||
urban_hikes_render_nav();
|
||||
?>
|
||||
<main>
|
||||
<section class="section-shell border-bottom">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5">
|
||||
<?php if ($flash): ?>
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<div class="toast app-toast text-bg-dark border-0" id="appToast" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body"><?= htmlspecialchars($flash['message']) ?></div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$route): ?>
|
||||
<div class="panel-card empty-state text-center">
|
||||
<span class="eyebrow">Route missing</span>
|
||||
<h1 class="h4 mt-2">We could not find that route.</h1>
|
||||
<p class="text-muted mb-4">It may have been removed or the link is incomplete.</p>
|
||||
<a class="btn btn-dark" href="index.php#results">Back to directory</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small mb-0">
|
||||
<li class="breadcrumb-item"><a href="index.php">Directory</a></li>
|
||||
<li class="breadcrumb-item"><a href="city.php?city=<?= rawurlencode((string)$route['city']) ?>"><?= htmlspecialchars($route['city']) ?></a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?= htmlspecialchars($route['title']) ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-8">
|
||||
<article class="panel-card route-detail-card">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars($route['city']) ?></span>
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars($route['difficulty']) ?></span>
|
||||
<span class="badge text-bg-light border"><?= htmlspecialchars($route['neighborhood']) ?></span>
|
||||
</div>
|
||||
<h1 class="detail-title mb-3"><?= htmlspecialchars($route['title']) ?></h1>
|
||||
<p class="lead-copy mb-4"><?= htmlspecialchars($route['summary']) ?></p>
|
||||
<div class="detail-metrics mb-4">
|
||||
<div class="metric-chip">
|
||||
<span class="metric-chip-label">Distance</span>
|
||||
<strong><?= htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km</strong>
|
||||
</div>
|
||||
<div class="metric-chip">
|
||||
<span class="metric-chip-label">Duration</span>
|
||||
<strong><?= htmlspecialchars(number_format((float)$route['duration_hours'], 1)) ?> hr</strong>
|
||||
</div>
|
||||
<div class="metric-chip">
|
||||
<span class="metric-chip-label">Start point</span>
|
||||
<strong><?= htmlspecialchars($route['start_point']) ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider mb-4"></div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-7">
|
||||
<h2 class="section-title h5 mb-3">Route highlights</h2>
|
||||
<ul class="highlights-list large mb-0">
|
||||
<?php foreach (urban_hikes_highlight_items((string)$route['highlights']) as $item): ?>
|
||||
<li><?= htmlspecialchars($item) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="detail-aside-card">
|
||||
<h2 class="section-title h6 mb-3">Planning notes</h2>
|
||||
<dl class="detail-list mb-0">
|
||||
<div>
|
||||
<dt>Best for</dt>
|
||||
<dd><?= htmlspecialchars($route['best_for']) ?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Area</dt>
|
||||
<dd><?= htmlspecialchars($route['neighborhood']) ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<a class="btn btn-dark w-100 mt-4" href="<?= htmlspecialchars($route['map_url']) ?>" target="_blank" rel="noopener">Open route map</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="panel-card mb-3">
|
||||
<span class="eyebrow">Next move</span>
|
||||
<h2 class="section-title h5 mt-2">Want to add another city route?</h2>
|
||||
<p class="text-muted mb-4">Use the lightweight admin screen to submit a new urban hike and make it searchable right away.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<a class="btn btn-outline-secondary" href="city.php?city=<?= rawurlencode((string)$route['city']) ?>">Open city guide</a>
|
||||
<a class="btn btn-outline-secondary" href="admin.php?id=<?= (int)$route['id'] ?>">Edit this route</a>
|
||||
<a class="btn btn-outline-secondary" href="admin.php">Add a route</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<span class="eyebrow">More in <?= htmlspecialchars($route['city']) ?></span>
|
||||
<h2 class="section-title h5 mt-2">Related routes</h2>
|
||||
<?php if (!$related): ?>
|
||||
<p class="text-muted mb-0">No related routes in this city yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="vstack gap-3 mt-3">
|
||||
<?php foreach ($related as $item): ?>
|
||||
<a class="subroute-link" href="route.php?id=<?= (int)$item['id'] ?>">
|
||||
<strong><?= htmlspecialchars($item['title']) ?></strong>
|
||||
<span><?= htmlspecialchars(number_format((float)$item['distance_km'], 1)) ?> km · <?= htmlspecialchars($item['difficulty']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php urban_hikes_render_footer(); ?>
|
||||
563
urban_hikes.php
Normal file
563
urban_hikes.php
Normal file
@ -0,0 +1,563 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
function urban_hikes_project_name(): string
|
||||
{
|
||||
return trim((string)($_SERVER['PROJECT_NAME'] ?? 'Urban Hike Atlas')) ?: 'Urban Hike Atlas';
|
||||
}
|
||||
|
||||
function urban_hikes_asset_url(string $relativePath): string
|
||||
{
|
||||
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
|
||||
$version = file_exists($fullPath) ? (string)filemtime($fullPath) : (string)time();
|
||||
return $relativePath . '?v=' . rawurlencode($version);
|
||||
}
|
||||
|
||||
function urban_hikes_storage(): array
|
||||
{
|
||||
static $storage = null;
|
||||
|
||||
if ($storage !== null) {
|
||||
return $storage;
|
||||
}
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo = db();
|
||||
urban_hikes_ensure_schema($pdo);
|
||||
urban_hikes_seed_demo_routes($pdo);
|
||||
$storage = ['ready' => true, 'pdo' => $pdo, 'error' => null];
|
||||
} catch (Throwable $e) {
|
||||
$storage = ['ready' => false, 'pdo' => null, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return $storage;
|
||||
}
|
||||
|
||||
function urban_hikes_ensure_schema(PDO $pdo): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS urban_routes (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
slug VARCHAR(180) NOT NULL UNIQUE,
|
||||
summary VARCHAR(255) NOT NULL,
|
||||
distance_km DECIMAL(4,1) NOT NULL,
|
||||
duration_hours DECIMAL(3,1) NOT NULL,
|
||||
difficulty VARCHAR(20) NOT NULL,
|
||||
neighborhood VARCHAR(120) NOT NULL,
|
||||
start_point VARCHAR(160) NOT NULL,
|
||||
highlights TEXT NOT NULL,
|
||||
map_url VARCHAR(255) NOT NULL,
|
||||
best_for VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL;
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
function urban_hikes_seed_demo_routes(PDO $pdo): void
|
||||
{
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM urban_routes')->fetchColumn();
|
||||
if ($count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$routes = [
|
||||
[
|
||||
'city' => 'New York',
|
||||
'title' => 'Riverside to Harlem Heights Loop',
|
||||
'summary' => 'A long riverside climb with park viewpoints, brownstone blocks, and a high-energy finish near Hamilton Heights.',
|
||||
'distance_km' => 9.4,
|
||||
'duration_hours' => 2.8,
|
||||
'difficulty' => 'Moderate',
|
||||
'neighborhood' => 'Upper West Side & Harlem',
|
||||
'start_point' => '72nd Street and Riverside Drive',
|
||||
'highlights' => "Hudson River Greenway\nGrant\'s Tomb\nCity College steps\nHamilton Heights coffee stops",
|
||||
'map_url' => 'https://maps.google.com/?q=Riverside+Drive+New+York',
|
||||
'best_for' => 'First-time visitors who want skyline views with real neighborhood texture',
|
||||
],
|
||||
[
|
||||
'city' => 'London',
|
||||
'title' => 'Regent\'s Canal to Primrose Hill Walk',
|
||||
'summary' => 'An easy canal walk with quiet waterside stretches, markets, and one of London\'s best panoramic hilltops.',
|
||||
'distance_km' => 7.1,
|
||||
'duration_hours' => 2.2,
|
||||
'difficulty' => 'Easy',
|
||||
'neighborhood' => 'Little Venice to Camden',
|
||||
'start_point' => 'Paddington Basin',
|
||||
'highlights' => "Houseboat-lined canal\nCamden Lock food scene\nPrimrose Hill lookout\nRegent\'s Park edge paths",
|
||||
'map_url' => 'https://maps.google.com/?q=Paddington+Basin+London',
|
||||
'best_for' => 'Travelers with half a day and a camera',
|
||||
],
|
||||
[
|
||||
'city' => 'Tokyo',
|
||||
'title' => 'Meiji Shrine to Shibuya Ridge Circuit',
|
||||
'summary' => 'A city hike that shifts from tranquil forested shrine paths into dense retail streets and rooftop views.',
|
||||
'distance_km' => 8.0,
|
||||
'duration_hours' => 2.5,
|
||||
'difficulty' => 'Moderate',
|
||||
'neighborhood' => 'Harajuku & Shibuya',
|
||||
'start_point' => 'Meiji Jingumae Station',
|
||||
'highlights' => "Meiji Shrine grove\nYoyogi Park edges\nCat Street detour\nShibuya Sky finale",
|
||||
'map_url' => 'https://maps.google.com/?q=Meiji+Jingumae+Station+Tokyo',
|
||||
'best_for' => 'Visitors who want a calm-to-busy Tokyo contrast',
|
||||
],
|
||||
[
|
||||
'city' => 'Paris',
|
||||
'title' => 'Canal Saint-Martin to Montmartre Stairs',
|
||||
'summary' => 'A compact urban hike with waterside strolling, café stops, and a rewarding climb to Sacré-Cœur.',
|
||||
'distance_km' => 6.3,
|
||||
'duration_hours' => 2.0,
|
||||
'difficulty' => 'Moderate',
|
||||
'neighborhood' => '10th to 18th arrondissement',
|
||||
'start_point' => 'Jardin Villemin',
|
||||
'highlights' => "Canal Saint-Martin bridges\nHidden stair streets\nMoulin Rouge edge\nSacré-Cœur terrace",
|
||||
'map_url' => 'https://maps.google.com/?q=Jardin+Villemin+Paris',
|
||||
'best_for' => 'Travelers who want a scenic climb without leaving the center',
|
||||
],
|
||||
[
|
||||
'city' => 'Chicago',
|
||||
'title' => 'Lakefront to Lincoln Park Connector',
|
||||
'summary' => 'A flat, breezy route along the lake with skyline photo points, gardens, and museum-side paths.',
|
||||
'distance_km' => 10.2,
|
||||
'duration_hours' => 3.0,
|
||||
'difficulty' => 'Easy',
|
||||
'neighborhood' => 'Streeterville to Lincoln Park',
|
||||
'start_point' => 'Navy Pier entrance',
|
||||
'highlights' => "Lakefront Trail\nOlive Park skyline angle\nNorth Avenue Beach\nLincoln Park Conservatory",
|
||||
'map_url' => 'https://maps.google.com/?q=Navy+Pier+Chicago',
|
||||
'best_for' => 'Active mornings with lots of photo stops',
|
||||
],
|
||||
[
|
||||
'city' => 'Mexico City',
|
||||
'title' => 'Chapultepec to Roma Norte Urban Trek',
|
||||
'summary' => 'A green-to-cultural route crossing the park, museums, boulevards, and food-heavy side streets.',
|
||||
'distance_km' => 8.8,
|
||||
'duration_hours' => 2.7,
|
||||
'difficulty' => 'Challenging',
|
||||
'neighborhood' => 'Chapultepec & Roma Norte',
|
||||
'start_point' => 'Estela de Luz',
|
||||
'highlights' => "Chapultepec forest paths\nMuseum corridor\nAvenida Amsterdam loop\nRoma Norte cafés",
|
||||
'map_url' => 'https://maps.google.com/?q=Estela+de+Luz+Mexico+City',
|
||||
'best_for' => 'Walkers who want culture, food, and a full afternoon route',
|
||||
],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO urban_routes (city, title, slug, summary, distance_km, duration_hours, difficulty, neighborhood, start_point, highlights, map_url, best_for) VALUES (:city, :title, :slug, :summary, :distance_km, :duration_hours, :difficulty, :neighborhood, :start_point, :highlights, :map_url, :best_for)');
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$stmt->execute([
|
||||
':city' => $route['city'],
|
||||
':title' => $route['title'],
|
||||
':slug' => urban_hikes_slugify($route['title']),
|
||||
':summary' => $route['summary'],
|
||||
':distance_km' => $route['distance_km'],
|
||||
':duration_hours' => $route['duration_hours'],
|
||||
':difficulty' => $route['difficulty'],
|
||||
':neighborhood' => $route['neighborhood'],
|
||||
':start_point' => $route['start_point'],
|
||||
':highlights' => $route['highlights'],
|
||||
':map_url' => $route['map_url'],
|
||||
':best_for' => $route['best_for'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function urban_hikes_slugify(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?: 'route';
|
||||
return trim($value, '-') ?: 'route';
|
||||
}
|
||||
|
||||
function urban_hikes_unique_slug(PDO $pdo, string $title): string
|
||||
{
|
||||
$base = urban_hikes_slugify($title);
|
||||
$slug = $base;
|
||||
$suffix = 1;
|
||||
$check = $pdo->prepare('SELECT COUNT(*) FROM urban_routes WHERE slug = :slug');
|
||||
|
||||
while (true) {
|
||||
$check->execute([':slug' => $slug]);
|
||||
if ((int)$check->fetchColumn() === 0) {
|
||||
return $slug;
|
||||
}
|
||||
$suffix++;
|
||||
$slug = $base . '-' . $suffix;
|
||||
}
|
||||
}
|
||||
|
||||
function urban_hikes_fetch_filters(): array
|
||||
{
|
||||
$allowedDifficulties = ['Easy', 'Moderate', 'Challenging'];
|
||||
$difficulty = trim((string)($_GET['difficulty'] ?? ''));
|
||||
|
||||
return [
|
||||
'q' => trim((string)($_GET['q'] ?? '')),
|
||||
'city' => trim((string)($_GET['city'] ?? '')),
|
||||
'difficulty' => in_array($difficulty, $allowedDifficulties, true) ? $difficulty : '',
|
||||
'max_distance' => isset($_GET['max_distance']) && is_numeric((string)$_GET['max_distance']) ? (float)$_GET['max_distance'] : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
function urban_hikes_search(array $filters): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = 'SELECT * FROM urban_routes WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($filters['q'] !== '') {
|
||||
$sql .= ' AND (title LIKE :query OR summary LIKE :query OR highlights LIKE :query OR neighborhood LIKE :query)';
|
||||
$params[':query'] = '%' . $filters['q'] . '%';
|
||||
}
|
||||
if ($filters['city'] !== '') {
|
||||
$sql .= ' AND city = :city';
|
||||
$params[':city'] = $filters['city'];
|
||||
}
|
||||
if ($filters['difficulty'] !== '') {
|
||||
$sql .= ' AND difficulty = :difficulty';
|
||||
$params[':difficulty'] = $filters['difficulty'];
|
||||
}
|
||||
if ($filters['max_distance'] > 0) {
|
||||
$sql .= ' AND distance_km <= :max_distance';
|
||||
$params[':max_distance'] = $filters['max_distance'];
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY city ASC, distance_km ASC, title ASC';
|
||||
|
||||
$stmt = $storage['pdo']->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$type = is_numeric($value) ? PDO::PARAM_STR : PDO::PARAM_STR;
|
||||
$stmt->bindValue($key, $value, $type);
|
||||
}
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function urban_hikes_cities(): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $storage['pdo']->query('SELECT city, COUNT(*) AS route_count FROM urban_routes GROUP BY city ORDER BY route_count DESC, city ASC');
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function urban_hikes_stats(): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready']) {
|
||||
return ['routes' => 0, 'cities' => 0, 'avg_distance' => 0];
|
||||
}
|
||||
|
||||
$stmt = $storage['pdo']->query('SELECT COUNT(*) AS routes, COUNT(DISTINCT city) AS cities, AVG(distance_km) AS avg_distance FROM urban_routes');
|
||||
$stats = $stmt->fetch() ?: ['routes' => 0, 'cities' => 0, 'avg_distance' => 0];
|
||||
$stats['avg_distance'] = round((float)$stats['avg_distance'], 1);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
function urban_hikes_find(int $id): ?array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $storage['pdo']->prepare('SELECT * FROM urban_routes WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$route = $stmt->fetch();
|
||||
return $route ?: null;
|
||||
}
|
||||
|
||||
function urban_hikes_find_by_city(string $city): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready'] || $city === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $storage['pdo']->prepare('SELECT * FROM urban_routes WHERE city = :city ORDER BY distance_km ASC, title ASC');
|
||||
$stmt->execute([':city' => $city]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function urban_hikes_city_stats(string $city): ?array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready'] || $city === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = 'SELECT city, COUNT(*) AS route_count, AVG(distance_km) AS avg_distance, AVG(duration_hours) AS avg_duration, COUNT(DISTINCT neighborhood) AS area_count, SUM(CASE WHEN difficulty = "Easy" THEN 1 ELSE 0 END) AS easy_count, SUM(CASE WHEN difficulty = "Moderate" THEN 1 ELSE 0 END) AS moderate_count, SUM(CASE WHEN difficulty = "Challenging" THEN 1 ELSE 0 END) AS challenging_count FROM urban_routes WHERE city = :city GROUP BY city LIMIT 1';
|
||||
$stmt = $storage['pdo']->prepare($sql);
|
||||
$stmt->execute([':city' => $city]);
|
||||
$stats = $stmt->fetch();
|
||||
|
||||
return $stats ?: null;
|
||||
}
|
||||
|
||||
function urban_hikes_related(string $city, int $excludeId, int $limit = 3): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $storage['pdo']->prepare('SELECT * FROM urban_routes WHERE city = :city AND id <> :exclude_id ORDER BY distance_km ASC, title ASC LIMIT ' . (int)$limit);
|
||||
$stmt->execute([':city' => $city, ':exclude_id' => $excludeId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function urban_hikes_latest(int $limit = 8): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
if (!$storage['ready']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $storage['pdo']->query('SELECT * FROM urban_routes ORDER BY created_at DESC, id DESC LIMIT ' . (int)$limit);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function urban_hikes_validation(array $input): array
|
||||
{
|
||||
$difficulties = ['Easy', 'Moderate', 'Challenging'];
|
||||
$clean = [
|
||||
'city' => trim((string)($input['city'] ?? '')),
|
||||
'title' => trim((string)($input['title'] ?? '')),
|
||||
'summary' => trim((string)($input['summary'] ?? '')),
|
||||
'distance_km' => trim((string)($input['distance_km'] ?? '')),
|
||||
'duration_hours' => trim((string)($input['duration_hours'] ?? '')),
|
||||
'difficulty' => trim((string)($input['difficulty'] ?? '')),
|
||||
'neighborhood' => trim((string)($input['neighborhood'] ?? '')),
|
||||
'start_point' => trim((string)($input['start_point'] ?? '')),
|
||||
'highlights' => trim((string)($input['highlights'] ?? '')),
|
||||
'map_url' => trim((string)($input['map_url'] ?? '')),
|
||||
'best_for' => trim((string)($input['best_for'] ?? '')),
|
||||
];
|
||||
$errors = [];
|
||||
|
||||
if ($clean['city'] === '') {
|
||||
$errors['city'] = 'City is required.';
|
||||
}
|
||||
if ($clean['title'] === '') {
|
||||
$errors['title'] = 'Route title is required.';
|
||||
}
|
||||
if ($clean['summary'] === '' || strlen($clean['summary']) < 20) {
|
||||
$errors['summary'] = 'Add a short summary of at least 20 characters.';
|
||||
}
|
||||
if ($clean['distance_km'] === '' || !is_numeric($clean['distance_km']) || (float)$clean['distance_km'] <= 0) {
|
||||
$errors['distance_km'] = 'Distance must be a positive number.';
|
||||
}
|
||||
if ($clean['duration_hours'] === '' || !is_numeric($clean['duration_hours']) || (float)$clean['duration_hours'] <= 0) {
|
||||
$errors['duration_hours'] = 'Duration must be a positive number.';
|
||||
}
|
||||
if (!in_array($clean['difficulty'], $difficulties, true)) {
|
||||
$errors['difficulty'] = 'Choose a valid difficulty.';
|
||||
}
|
||||
if ($clean['neighborhood'] === '') {
|
||||
$errors['neighborhood'] = 'Neighborhood or area is required.';
|
||||
}
|
||||
if ($clean['start_point'] === '') {
|
||||
$errors['start_point'] = 'Start point is required.';
|
||||
}
|
||||
if ($clean['highlights'] === '') {
|
||||
$errors['highlights'] = 'Add at least one highlight.';
|
||||
}
|
||||
if ($clean['map_url'] === '' || filter_var($clean['map_url'], FILTER_VALIDATE_URL) === false) {
|
||||
$errors['map_url'] = 'Map link must be a valid URL.';
|
||||
}
|
||||
if ($clean['best_for'] === '') {
|
||||
$errors['best_for'] = 'Describe who this route is best for.';
|
||||
}
|
||||
|
||||
return [$clean, $errors];
|
||||
}
|
||||
|
||||
function urban_hikes_create(array $input): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
[$clean, $errors] = urban_hikes_validation($input);
|
||||
|
||||
if (!$storage['ready']) {
|
||||
return ['success' => false, 'errors' => ['storage' => 'Database is currently unavailable.'], 'input' => $clean];
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
return ['success' => false, 'errors' => $errors, 'input' => $clean];
|
||||
}
|
||||
|
||||
$slug = urban_hikes_unique_slug($storage['pdo'], $clean['title']);
|
||||
$stmt = $storage['pdo']->prepare('INSERT INTO urban_routes (city, title, slug, summary, distance_km, duration_hours, difficulty, neighborhood, start_point, highlights, map_url, best_for) VALUES (:city, :title, :slug, :summary, :distance_km, :duration_hours, :difficulty, :neighborhood, :start_point, :highlights, :map_url, :best_for)');
|
||||
$stmt->execute([
|
||||
':city' => $clean['city'],
|
||||
':title' => $clean['title'],
|
||||
':slug' => $slug,
|
||||
':summary' => $clean['summary'],
|
||||
':distance_km' => round((float)$clean['distance_km'], 1),
|
||||
':duration_hours' => round((float)$clean['duration_hours'], 1),
|
||||
':difficulty' => $clean['difficulty'],
|
||||
':neighborhood' => $clean['neighborhood'],
|
||||
':start_point' => $clean['start_point'],
|
||||
':highlights' => $clean['highlights'],
|
||||
':map_url' => $clean['map_url'],
|
||||
':best_for' => $clean['best_for'],
|
||||
]);
|
||||
|
||||
return ['success' => true, 'id' => (int)$storage['pdo']->lastInsertId()];
|
||||
}
|
||||
|
||||
function urban_hikes_update(int $id, array $input): array
|
||||
{
|
||||
$storage = urban_hikes_storage();
|
||||
[$clean, $errors] = urban_hikes_validation($input);
|
||||
|
||||
if (!$storage['ready']) {
|
||||
return ['success' => false, 'errors' => ['storage' => 'Database is currently unavailable.'], 'input' => $clean];
|
||||
}
|
||||
|
||||
$existing = urban_hikes_find($id);
|
||||
if (!$existing) {
|
||||
return ['success' => false, 'errors' => ['storage' => 'That route could not be found.'], 'input' => $clean];
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
return ['success' => false, 'errors' => $errors, 'input' => $clean];
|
||||
}
|
||||
|
||||
$slug = $existing['title'] === $clean['title'] ? (string)$existing['slug'] : urban_hikes_unique_slug($storage['pdo'], $clean['title']);
|
||||
$stmt = $storage['pdo']->prepare('UPDATE urban_routes SET city = :city, title = :title, slug = :slug, summary = :summary, distance_km = :distance_km, duration_hours = :duration_hours, difficulty = :difficulty, neighborhood = :neighborhood, start_point = :start_point, highlights = :highlights, map_url = :map_url, best_for = :best_for WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([
|
||||
':city' => $clean['city'],
|
||||
':title' => $clean['title'],
|
||||
':slug' => $slug,
|
||||
':summary' => $clean['summary'],
|
||||
':distance_km' => round((float)$clean['distance_km'], 1),
|
||||
':duration_hours' => round((float)$clean['duration_hours'], 1),
|
||||
':difficulty' => $clean['difficulty'],
|
||||
':neighborhood' => $clean['neighborhood'],
|
||||
':start_point' => $clean['start_point'],
|
||||
':highlights' => $clean['highlights'],
|
||||
':map_url' => $clean['map_url'],
|
||||
':best_for' => $clean['best_for'],
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
return ['success' => true, 'id' => $id];
|
||||
}
|
||||
|
||||
function urban_hikes_highlight_items(string $value): array
|
||||
{
|
||||
$items = preg_split('/\r\n|\r|\n/', $value) ?: [];
|
||||
$items = array_values(array_filter(array_map('trim', $items)));
|
||||
if ($items) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
$items = preg_split('/,/', $value) ?: [];
|
||||
return array_values(array_filter(array_map('trim', $items)));
|
||||
}
|
||||
|
||||
function urban_hikes_set_flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['urban_hikes_flash'] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
function urban_hikes_get_flash(): ?array
|
||||
{
|
||||
if (!isset($_SESSION['urban_hikes_flash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$flash = $_SESSION['urban_hikes_flash'];
|
||||
unset($_SESSION['urban_hikes_flash']);
|
||||
return is_array($flash) ? $flash : null;
|
||||
}
|
||||
|
||||
function urban_hikes_render_head(string $title, string $description, string $robots = 'index, follow'): void
|
||||
{
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
$metaDescription = $description !== '' ? $description : $projectDescription;
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><?= htmlspecialchars($title) ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>" />
|
||||
<meta name="robots" content="<?= htmlspecialchars($robots) ?>" />
|
||||
<?php if ($projectDescription): ?>
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars(urban_hikes_asset_url('assets/css/custom.css')) ?>" />
|
||||
</head>
|
||||
<body>
|
||||
<?php
|
||||
}
|
||||
|
||||
function urban_hikes_render_nav(string $active = 'directory'): void
|
||||
{
|
||||
?>
|
||||
<header class="topbar sticky-top border-bottom">
|
||||
<nav class="navbar navbar-expand-lg">
|
||||
<div class="container-lg px-3 px-lg-4">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="index.php">
|
||||
<span class="brand-mark">UH</span>
|
||||
<span><?= htmlspecialchars(urban_hikes_project_name()) ?></span>
|
||||
</a>
|
||||
<button class="navbar-toggler shadow-none border-0 px-0" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
<li class="nav-item"><a class="nav-link <?= $active === 'directory' ? 'active' : '' ?>" href="index.php#results">Routes</a></li>
|
||||
<li class="nav-item"><a class="nav-link <?= $active === 'cities' ? 'active' : '' ?>" href="index.php#cities">Cities</a></li>
|
||||
<li class="nav-item"><a class="nav-link <?= $active === 'admin' ? 'active' : '' ?>" href="admin.php">Add route</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<?php
|
||||
}
|
||||
|
||||
function urban_hikes_render_footer(): void
|
||||
{
|
||||
?>
|
||||
<footer class="site-footer border-top">
|
||||
<div class="container-lg px-3 px-lg-4 py-4 d-flex flex-column flex-md-row gap-2 justify-content-between align-items-md-center">
|
||||
<p class="mb-0 text-muted small">Urban hiking routes for city days that still feel active.</p>
|
||||
<div class="d-flex gap-3 small">
|
||||
<a href="index.php#results">Browse directory</a>
|
||||
<a href="admin.php">Submit a route</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script src="<?= htmlspecialchars(urban_hikes_asset_url('assets/js/main.js')) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user