564 lines
22 KiB
PHP
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
|
|
}
|