Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

9 changed files with 490 additions and 1724 deletions

249
admin.php
View File

@ -1,249 +0,0 @@
<?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(); ?>

View File

@ -1,443 +1,403 @@
: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 { body {
background: var(--bg); background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
color: var(--text); background-size: 400% 400%;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 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;
min-height: 100vh; min-height: 100vh;
} }
a { .main-wrapper {
color: inherit; display: flex;
}
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; align-items: center;
justify-content: center; justify-content: center;
background: var(--accent); min-height: 100vh;
color: #fff; width: 100%;
border-radius: 10px; padding: 20px;
font-size: 0.78rem; box-sizing: border-box;
position: relative;
z-index: 1;
} }
.nav-link { @keyframes gradient {
color: var(--muted); 0% {
font-size: 0.95rem; background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
} }
.nav-link.active, .chat-container {
.nav-link:hover { width: 100%;
color: var(--text); 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;
} }
.hero-shell, .chat-header {
.section-shell { 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; background: transparent;
} }
.py-lg-6 { ::-webkit-scrollbar-thumb {
padding-top: 5rem; background: rgba(255, 255, 255, 0.3);
padding-bottom: 5rem; border-radius: 10px;
} }
.eyebrow { ::-webkit-scrollbar-thumb:hover {
display: inline-flex; background: rgba(255, 255, 255, 0.5);
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
} }
.display-title, .message {
.detail-title { max-width: 85%;
font-size: clamp(2rem, 4vw, 3.6rem); padding: 0.85rem 1.1rem;
line-height: 1.02; border-radius: 16px;
letter-spacing: -0.04em; line-height: 1.5;
font-weight: 700; font-size: 0.95rem;
max-width: 12ch; 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);
} }
.lead-copy { @keyframes fadeIn {
color: var(--muted); from { opacity: 0; transform: translateY(20px) scale(0.95); }
font-size: 1rem; to { opacity: 1; transform: translateY(0) scale(1); }
line-height: 1.65;
max-width: 64ch;
} }
.section-title { .message.visitor {
font-size: 1.45rem; align-self: flex-end;
line-height: 1.1; background: linear-gradient(135deg, #212529 0%, #343a40 100%);
letter-spacing: -0.03em; color: #fff;
font-weight: 650; border-bottom-right-radius: 4px;
} }
.panel-card, .message.bot {
.detail-aside-card { align-self: flex-start;
background: var(--surface); background: #ffffff;
border: 1px solid var(--line); color: #212529;
border-radius: var(--radius-lg); border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm); }
.chat-input-area {
padding: 1.25rem; padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
} }
.stats-panel { .chat-input-area form {
background: var(--surface-alt); display: flex;
gap: 0.75rem;
} }
.metric-label { .chat-input-area input {
margin-bottom: 0.2rem; flex: 1;
font-size: 0.75rem; border: 1px solid rgba(0, 0, 0, 0.1);
text-transform: uppercase; border-radius: 12px;
letter-spacing: 0.08em; padding: 0.75rem 1rem;
color: var(--muted);
}
.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;
color: var(--text);
box-shadow: none !important;
}
textarea.form-control {
min-height: auto;
}
.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; outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
} }
.btn { .chat-input-area input:focus {
border-radius: 9px; border-color: #23a6d5;
padding: 0.78rem 1rem; 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; font-weight: 600;
transition: all 0.3s ease;
} }
.btn-dark { .chat-input-area button:hover {
background: var(--accent);
border-color: var(--accent);
}
.btn-dark:hover,
.btn-dark:focus {
background: #000; 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); transform: translateY(-2px);
box-shadow: var(--shadow-md); box-shadow: 0 5px 15px rgba(0,0,0,0.2);
border-color: #c8ccd2;
} }
.route-title { /* Background Animations */
font-size: 1.2rem; .bg-animations {
line-height: 1.2; position: fixed;
letter-spacing: -0.03em; top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
} }
.route-title a { .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; text-decoration: none;
} }
.route-meta, /* Admin Styles */
.detail-metrics { .admin-container {
display: flex; max-width: 900px;
flex-wrap: wrap; margin: 3rem auto;
gap: 0.65rem; 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;
} }
.route-meta span, .admin-container h1 {
.metric-chip { margin-top: 0;
display: inline-flex; color: #212529;
align-items: center; font-weight: 800;
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 { .table {
flex-direction: column; width: 100%;
align-items: flex-start; border-collapse: separate;
border-radius: var(--radius-md); border-spacing: 0 8px;
min-width: 140px; margin-top: 1.5rem;
} }
.metric-chip strong { .table th {
color: var(--text); background: transparent;
font-size: 0.95rem; border: none;
} padding: 1rem;
color: #6c757d;
.metric-chip-label { font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; font-size: 0.75rem;
color: var(--muted); letter-spacing: 1px;
} }
.route-actions { .table td {
min-width: 160px; background: #fff;
padding: 1rem;
border: none;
} }
.empty-state { .table tr td:first-child { border-radius: 12px 0 0 12px; }
text-align: left; .table tr td:last-child { border-radius: 0 12px 12px 0; }
padding: 2rem;
.form-group {
margin-bottom: 1.25rem;
} }
.city-card { .form-group label {
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; display: block;
} margin-bottom: 0.5rem;
font-weight: 600;
.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; font-size: 0.9rem;
} }
.sticky-filter-card { .form-control {
position: sticky; width: 100%;
top: 5.5rem; 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;
} }
.detail-list { .form-control:focus {
display: grid; 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;
gap: 1rem; gap: 1rem;
} }
.detail-list dt { .admin-card {
margin-bottom: 0.2rem; background: rgba(255, 255, 255, 0.6);
font-size: 0.74rem; padding: 2rem;
text-transform: uppercase; border-radius: 20px;
letter-spacing: 0.08em; border: 1px solid rgba(255, 255, 255, 0.5);
color: var(--muted); margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
} }
.detail-list dd { .admin-card h3 {
margin-bottom: 0; margin-top: 0;
color: var(--text); margin-bottom: 1.5rem;
font-weight: 700;
} }
.subroute-link { .btn-delete {
display: flex; background: #dc3545;
flex-direction: column; color: white;
gap: 0.2rem; border: none;
padding: 0.85rem 0.95rem; padding: 0.25rem 0.5rem;
border: 1px solid var(--line); border-radius: 4px;
border-radius: var(--radius-md); cursor: pointer;
text-decoration: none;
background: var(--surface-alt);
} }
.subroute-link span { .btn-add {
color: var(--muted); background: #212529;
font-size: 0.9rem; color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
} }
.admin-table th { .btn-save {
font-size: 0.76rem; background: #0088cc;
text-transform: uppercase; color: white;
letter-spacing: 0.08em; border: none;
color: var(--muted); padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
} }
.admin-table td, .webhook-url {
.admin-table th { font-size: 0.85em;
border-color: var(--line); color: #555;
padding-block: 0.8rem; margin-top: 0.5rem;
} }
.app-toast { .history-table-container {
border-radius: var(--radius-md); overflow-x: auto;
box-shadow: var(--shadow-md); background: rgba(255, 255, 255, 0.4);
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
} }
.badge.text-bg-light { .history-table {
background: var(--surface-alt) !important; width: 100%;
color: var(--text) !important;
font-weight: 500;
} }
.breadcrumb { .history-table-time {
--bs-breadcrumb-divider-color: var(--muted); width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
} }
.breadcrumb a { .history-table-user {
color: var(--muted); width: 35%;
text-decoration: none; background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
} }
.site-footer a { .history-table-ai {
color: var(--muted); width: 50%;
text-decoration: none; background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
} }
.site-footer a:hover, .no-messages {
.breadcrumb a:hover, text-align: center;
.route-title a:hover { color: #777;
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;
}
} }

View File

@ -1,31 +1,39 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const toastEl = document.getElementById('appToast'); const chatForm = document.getElementById('chat-form');
if (toastEl && window.bootstrap) { const chatInput = document.getElementById('chat-input');
const toast = new window.bootstrap.Toast(toastEl, { delay: 4200 }); const chatMessages = document.getElementById('chat-messages');
toast.show();
}
if (window.location.hash === '#results') { const appendMessage = (text, sender) => {
document.getElementById('results')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); const msgDiv = document.createElement('div');
} msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
document.querySelectorAll('[data-autosubmit="change"]').forEach((field) => { chatForm.addEventListener('submit', async (e) => {
field.addEventListener('change', () => { e.preventDefault();
if (field.form) { const message = chatInput.value.trim();
field.form.requestSubmit(); if (!message) return;
}
});
});
document.querySelectorAll('[data-count-target]').forEach((field) => { appendMessage(message, 'visitor');
const counter = document.getElementById(field.dataset.countTarget || ''); chatInput.value = '';
if (!counter) {
return; 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');
} }
const update = () => {
counter.textContent = `${field.value.trim().length} chars`;
};
update();
field.addEventListener('input', update);
}); });
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

191
city.php
View File

@ -1,191 +0,0 @@
<?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
View File

@ -1,213 +1,150 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/urban_hikes.php'; @ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$filters = urban_hikes_fetch_filters(); $phpVersion = PHP_VERSION;
$routes = urban_hikes_search($filters); $now = date('Y-m-d H:i:s');
$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');
?> ?>
<main> <!doctype html>
<section class="hero-shell border-bottom"> <html lang="en">
<div class="container-lg px-3 px-lg-4 py-5 py-lg-6"> <head>
<div class="row g-4 align-items-end"> <meta charset="utf-8" />
<div class="col-lg-7"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<span class="eyebrow">Urban hiking directory</span> <title>New Style</title>
<h1 class="display-title mt-3 mb-3">Big-city routes for locals, visitors, and long walking days.</h1> <?php
<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> // Read project preview data from environment
<div class="d-flex flex-wrap gap-2"> $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
<a class="btn btn-dark px-4" href="#results">Browse routes</a> $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<a class="btn btn-outline-secondary px-4" href="admin.php">Add a route</a> ?>
</div> <?php if ($projectDescription): ?>
</div> <!-- Meta description -->
<div class="col-lg-5"> <meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<div class="panel-card stats-panel h-100"> <!-- Open Graph meta tags -->
<div class="row g-3"> <meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<div class="col-4"> <!-- Twitter meta tags -->
<p class="metric-label">Routes</p> <meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<p class="metric-value"><?= (int)$stats['routes'] ?></p> <?php endif; ?>
</div> <?php if ($projectImageUrl): ?>
<div class="col-4"> <!-- Open Graph image -->
<p class="metric-label">Cities</p> <meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<p class="metric-value"><?= (int)$stats['cities'] ?></p> <!-- Twitter image -->
</div> <meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<div class="col-4"> <?php endif; ?>
<p class="metric-label">Avg km</p> <link rel="preconnect" href="https://fonts.googleapis.com">
<p class="metric-value"><?= htmlspecialchars(number_format((float)$stats['avg_distance'], 1)) ?></p> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</div> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
</div> <style>
<div class="divider my-4"></div> :root {
<p class="small text-muted mb-2">Useful when you want:</p> --bg-color-start: #6a11cb;
<ul class="compact-list mb-0"> --bg-color-end: #2575fc;
<li>A scenic 23 hour city plan</li> --text-color: #ffffff;
<li>A route that matches your walking energy</li> --card-bg-color: rgba(255, 255, 255, 0.01);
<li>Quick route comparison before you leave the hotel</li> --card-border-color: rgba(255, 255, 255, 0.1);
</ul> }
</div> body {
</div> 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>
</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> </div>
</section> </main>
<footer>
<section class="section-shell border-bottom" id="results"> Page updated: <?= htmlspecialchars($now) ?> (UTC)
<div class="container-lg px-3 px-lg-4 py-4 py-lg-5"> </footer>
<?php if ($flash): ?> </body>
<div class="toast-container position-fixed top-0 end-0 p-3"> </html>
<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
View File

@ -1,136 +0,0 @@
<?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(); ?>

View File

@ -1,563 +0,0 @@
<?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
}