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.
+
+
+
+ Database saving is unavailable right now, so new routes cannot be published yet.
+
+
+
+
+
= htmlspecialchars($errors['storage']) ?>
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+ Route
+ City
+ View
+
+
+
+
+
+
+ = htmlspecialchars($route['title']) ?>
+ = htmlspecialchars($route['difficulty']) ?> · = htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km
+
+ = htmlspecialchars($route['city']) ?>
+ 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
+
= (int)$stats['routes'] ?>
+
+
+
Cities
+
= (int)$stats['cities'] ?>
+
+
+
Avg km
+
= htmlspecialchars(number_format((float)$stats['avg_distance'], 1)) ?>
+
+
+
+
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
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
-
-
+
+
+
+
+
+
+
+
+
= htmlspecialchars($flash['message']) ?>
+
+
+
+
+
+
+
+
+ Route browsing is visible, but saving new routes is temporarily unavailable because the database connection failed.
+
+
+
+
+
+
+
+
+ Find a route
+
Search filters
+
+
Reset
+
+
+
+ Keyword
+
+
+
+ City
+
+ All cities
+
+ >= htmlspecialchars($cityRow['city']) ?>
+
+
+
+
+ Difficulty
+
+ Any level
+
+ >= htmlspecialchars($level) ?>
+
+
+
+
+ Maximum distance
+
+ Any length
+
+ >Up to = $distance ?> km
+
+
+
+
+
Apply filters
+
Clear
+
+
+
+
+
+
+
+
+
Directory
+
= count($routes) ?> route= count($routes) === 1 ? '' : 's' ?> 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
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($route['city']) ?>
+ = htmlspecialchars($route['difficulty']) ?>
+ = htmlspecialchars($route['neighborhood']) ?>
+
+
+
= htmlspecialchars($route['summary']) ?>
+
+ = htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km
+ = htmlspecialchars(number_format((float)$route['duration_hours'], 1)) ?> hr
+ Start: = htmlspecialchars($route['start_point']) ?>
+
+
+
+
+ = htmlspecialchars($item) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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();
+?>
+
+
+
+
+
+
+
+
= htmlspecialchars($flash['message']) ?>
+
+
+
+
+
+
+
+
+
Route missing
+
We could not find that route.
+
It may have been removed or the link is incomplete.
+
Back to directory
+
+
+
+
+ Directory
+ = htmlspecialchars($route['city']) ?>
+ = htmlspecialchars($route['title']) ?>
+
+
+
+
+
+
+
+ = htmlspecialchars($route['city']) ?>
+ = htmlspecialchars($route['difficulty']) ?>
+ = htmlspecialchars($route['neighborhood']) ?>
+
+ = htmlspecialchars($route['title']) ?>
+ = htmlspecialchars($route['summary']) ?>
+
+
+ Distance
+ = htmlspecialchars(number_format((float)$route['distance_km'], 1)) ?> km
+
+
+ Duration
+ = htmlspecialchars(number_format((float)$route['duration_hours'], 1)) ?> hr
+
+
+ Start point
+ = htmlspecialchars($route['start_point']) ?>
+
+
+
+
+
+
Route highlights
+
+
+ = htmlspecialchars($item) ?>
+
+
+
+
+
+
Planning notes
+
+
+
Best for
+ = htmlspecialchars($route['best_for']) ?>
+
+
+
Area
+ = htmlspecialchars($route['neighborhood']) ?>
+
+
+
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 = htmlspecialchars($route['city']) ?>
+
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) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+