diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..947717b --- /dev/null +++ b/admin.php @@ -0,0 +1,187 @@ + '', + 'title' => '', + 'summary' => '', + 'distance_km' => '', + 'duration_hours' => '', + 'difficulty' => 'Moderate', + 'neighborhood' => '', + 'start_point' => '', + 'highlights' => '', + 'map_url' => '', + 'best_for' => '', +]; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $result = urban_hikes_create($_POST); + if (!empty($result['success'])) { + urban_hikes_set_flash('success', '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'] ?? []); +} + +$latestRoutes = urban_hikes_latest(); +$pageTitle = 'Add a route | ' . urban_hikes_project_name(); +$pageDescription = 'Add an urban hiking route with city, difficulty, distance, highlights, and map link.'; + +urban_hikes_render_head($pageTitle, $pageDescription, 'noindex, follow'); +urban_hikes_render_nav('admin'); +?> +
+
+
+
+
+
+ Content admin +

Add a new urban route

+

This first admin screen keeps the workflow small: add a route once, then browse it in the public directory immediately.

+ + + + + + + + + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
Enter one stop or viewpoint per line.
+
+
+
+ + +
+
+
+ + +
+
+
+ + Back to directory +
+
+
+
+ +
+
+ Workflow +

What this first slice covers

+
    +
  • Add a route with the key planning fields
  • +
  • Store it in MariaDB with prepared statements
  • +
  • Redirect to a detail page with a confirmation toast
  • +
  • See it in the public directory immediately
  • +
+
+
+
+
+ Latest entries +

Recent routes

+
+ Open directory +
+ +

No routes published yet.

+ +
+ + + + + + + + + + + + + + + + + +
RouteCityView
+
+
· km
+
Open
+
+ +
+
+
+
+
+
+ diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..73560ec 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,443 @@ +:root { + --bg: #f4f4f2; + --surface: #ffffff; + --surface-alt: #fafaf9; + --text: #171717; + --muted: #5f6368; + --line: #deded8; + --line-strong: #cfcfc8; + --accent: #111827; + --accent-soft: #eef1f4; + --success: #1f5132; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.04); + --shadow-md: 0 10px 24px rgba(17, 24, 39, 0.05); +} + +html { + scroll-behavior: smooth; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; + background: var(--bg); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; min-height: 100vh; } -.main-wrapper { - display: flex; +a { + color: inherit; +} + +a:hover { + color: inherit; +} + +.topbar, +.site-footer { + background: rgba(244, 244, 242, 0.96); + backdrop-filter: blur(10px); +} + +.navbar { + padding-block: 0.8rem; +} + +.navbar-brand { + font-size: 0.96rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-mark { + width: 2rem; + height: 2rem; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); + background: var(--accent); + color: #fff; border-radius: 10px; + font-size: 0.78rem; } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; +.nav-link { + color: var(--muted); font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } +.nav-link.active, +.nav-link:hover { + color: var(--text); } -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { +.hero-shell, +.section-shell { background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; +} + +.py-lg-6 { + padding-top: 5rem; + padding-bottom: 5rem; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + letter-spacing: 0.08em; text-transform: uppercase; + color: var(--muted); +} + +.display-title, +.detail-title { + font-size: clamp(2rem, 4vw, 3.6rem); + line-height: 1.02; + letter-spacing: -0.04em; + font-weight: 700; + max-width: 12ch; +} + +.lead-copy { + color: var(--muted); + font-size: 1rem; + line-height: 1.65; + max-width: 64ch; +} + +.section-title { + font-size: 1.45rem; + line-height: 1.1; + letter-spacing: -0.03em; + font-weight: 650; +} + +.panel-card, +.detail-aside-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 1.25rem; +} + +.stats-panel { + background: var(--surface-alt); +} + +.metric-label { + margin-bottom: 0.2rem; font-size: 0.75rem; - letter-spacing: 1px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); } -.table td { +.metric-value { + margin-bottom: 0; + font-size: 1.9rem; + line-height: 1; + letter-spacing: -0.04em; + font-weight: 700; +} + +.compact-list, +.highlights-list { + margin: 0; + padding-left: 1rem; + color: var(--muted); +} + +.compact-list li, +.highlights-list li { + margin-bottom: 0.55rem; +} + +.highlights-list.large li { + margin-bottom: 0.8rem; +} + +.divider { + height: 1px; + background: var(--line); +} + +.form-label, +.field-count, +.form-text, +.text-muted { + color: var(--muted) !important; +} + +.form-control, +.form-select { + min-height: 2.85rem; + border-color: var(--line-strong); + border-radius: var(--radius-sm); background: #fff; - padding: 1rem; - border: none; + color: var(--text); + box-shadow: none !important; } -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; +textarea.form-control { + min-height: auto; } -.form-group label { - display: block; - margin-bottom: 0.5rem; +.form-control:focus, +.form-select:focus, +.btn:focus, +.nav-link:focus, +.city-card:focus, +.subroute-link:focus, +.route-title a:focus { + border-color: #94a3b8; + box-shadow: 0 0 0 0.2rem rgba(148, 163, 184, 0.2) !important; + outline: none; +} + +.btn { + border-radius: 9px; + padding: 0.78rem 1rem; font-weight: 600; +} + +.btn-dark { + background: var(--accent); + border-color: var(--accent); +} + +.btn-dark:hover, +.btn-dark:focus { + background: #000; + border-color: #000; +} + +.btn-outline-secondary { + border-color: var(--line-strong); + color: var(--text); +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus { + background: var(--accent-soft); + color: var(--text); + border-color: var(--line-strong); +} + +.route-card { + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.route-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: #c8ccd2; +} + +.route-title { + font-size: 1.2rem; + line-height: 1.2; + letter-spacing: -0.03em; +} + +.route-title a { + text-decoration: none; +} + +.route-meta, +.detail-metrics { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.route-meta span, +.metric-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: var(--surface-alt); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.5rem 0.8rem; + font-size: 0.9rem; + color: var(--muted); +} + +.metric-chip { + flex-direction: column; + align-items: flex-start; + border-radius: var(--radius-md); + min-width: 140px; +} + +.metric-chip strong { + color: var(--text); + font-size: 0.95rem; +} + +.metric-chip-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.route-actions { + min-width: 160px; +} + +.empty-state { + text-align: left; + padding: 2rem; +} + +.city-card { + color: inherit; + transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; +} + +.city-card:hover { + transform: translateY(-2px); + border-color: #c8ccd2; + box-shadow: var(--shadow-md); +} + +.city-name, +.city-count { + display: block; +} + +.city-name { + font-size: 1rem; + font-weight: 650; + letter-spacing: -0.02em; +} + +.city-count { + margin-top: 0.35rem; + color: var(--muted); font-size: 0.9rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.sticky-filter-card { + position: sticky; + top: 5.5rem; } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { - display: flex; - justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; +.detail-list { + display: grid; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.detail-list dt { + margin-bottom: 0.2rem; + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; +.detail-list dd { + margin-bottom: 0; + color: var(--text); } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.subroute-link { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.85rem 0.95rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + text-decoration: none; + background: var(--surface-alt); } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.subroute-link span { + color: var(--muted); + font-size: 0.9rem; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; +.admin-table th { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.admin-table td, +.admin-table th { + border-color: var(--line); + padding-block: 0.8rem; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.app-toast { + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); } -.history-table { - width: 100%; +.badge.text-bg-light { + background: var(--surface-alt) !important; + color: var(--text) !important; + font-weight: 500; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.breadcrumb { + --bs-breadcrumb-divider-color: var(--muted); } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.breadcrumb a { + color: var(--muted); + text-decoration: none; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.site-footer a { + color: var(--muted); + text-decoration: none; } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.site-footer a:hover, +.breadcrumb a:hover, +.route-title a:hover { + color: var(--text); +} + +@media (max-width: 991.98px) { + .display-title, + .detail-title { + max-width: none; + } + + .sticky-filter-card { + position: static; + } + + .route-actions { + min-width: 0; + } +} + +@media (max-width: 575.98px) { + .panel-card, + .detail-aside-card { + padding: 1rem; + border-radius: var(--radius-md); + } + + .btn { + width: 100%; + } + + .route-actions { + flex-direction: column; + } + + .detail-metrics { + flex-direction: column; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..e4ca538 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,31 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const toastEl = document.getElementById('appToast'); + if (toastEl && window.bootstrap) { + const toast = new window.bootstrap.Toast(toastEl, { delay: 4200 }); + toast.show(); + } - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; + if (window.location.hash === '#results') { + document.getElementById('results')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + document.querySelectorAll('[data-autosubmit="change"]').forEach((field) => { + field.addEventListener('change', () => { + if (field.form) { + field.form.requestSubmit(); + } + }); + }); - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + document.querySelectorAll('[data-count-target]').forEach((field) => { + const counter = document.getElementById(field.dataset.countTarget || ''); + if (!counter) { + return; } + const update = () => { + counter.textContent = `${field.value.trim().length} chars`; + }; + update(); + field.addEventListener('input', update); }); }); diff --git a/index.php b/index.php index 7205f3d..0305718 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,211 @@ - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+
+ Urban hiking directory +

Big-city routes for locals, visitors, and long walking days.

+

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.

+ +
+
+
+
+
+

Routes

+

+
+
+

Cities

+

+
+
+

Avg km

+

+
+
+
+

Useful when you want:

+
    +
  • A scenic 2–3 hour city plan
  • +
  • A route that matches your walking energy
  • +
  • Quick route comparison before you leave the hotel
  • +
+
+
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
-
- Page updated: (UTC) -
- - + + +
+
+ +
+
+
+
+ +
+
+
+ + + + + + +
+
+
+
+
+ Find a route +

Search filters

+
+ Reset +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ +
+
+
+ Directory +

route matched

+

Compact route cards with the essentials first: timing, effort, area, and highlights.

+
+ Suggest a new route +
+ + +
+ No results +

No routes match these filters yet.

+

Try widening the distance or removing the city filter. You can also add your own route to start a new city collection.

+ Add a route +
+ +
+ + +
+ +
+ +
+ +
+
+
+
+ +
+
+
+
+ Cities +

Start from a city, then narrow it down.

+

Fast jumping-off points for the places already in the directory.

+
+
+
+ + + +
+
+
+
+ diff --git a/route.php b/route.php new file mode 100644 index 0000000..664eb58 --- /dev/null +++ b/route.php @@ -0,0 +1,132 @@ + 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(); +?> +
+
+
+ +
+
+
+
+ +
+
+
+ + + +
+ Route missing +

We could not find that route.

+

It may have been removed or the link is incomplete.

+ Back to directory +
+ + + +
+
+
+
+ + + +
+

+

+
+
+ Distance + km +
+
+ Duration + hr +
+
+ Start point + +
+
+
+
+
+

Route highlights

+
    + +
  • + +
+
+
+
+

Planning notes

+
+
+
Best for
+
+
+
+
Area
+
+
+
+ Open route map +
+
+
+
+
+
+
+ Next move +

Want to add another city route?

+

Use the lightweight admin screen to submit a new urban hike and make it searchable right away.

+ Add a route +
+
+ More in +

Related routes

+ +

No related routes in this city yet.

+ + + +
+
+
+ +
+
+
+ diff --git a/urban_hikes.php b/urban_hikes.php new file mode 100644 index 0000000..492cb9b --- /dev/null +++ b/urban_hikes.php @@ -0,0 +1,497 @@ + 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 = <<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_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_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; + ?> + + + + + + <?= htmlspecialchars($title) ?> + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +