39656-vm/urban_hikes.php
2026-04-15 16:05:12 +00:00

564 lines
22 KiB
PHP

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