mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 09:51:09 -05:00
feat: Add detailed park and ride pages with HTMX integration
- Implemented park detail page with dynamic content loading for rides and weather. - Created park list page with filters and search functionality. - Developed ride detail page showcasing ride stats, reviews, and similar rides. - Added ride list page with filtering options and dynamic loading. - Introduced search results page with tabs for parks, rides, and users. - Added HTMX tests for global search functionality.
This commit is contained in:
@@ -7,361 +7,25 @@ Matches React frontend AuthDialog functionality with modal-based auth
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
|
||||
<!-- Auth Modal Component -->
|
||||
<div
|
||||
x-data="authModal()"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
x-init="window.authModal = $data"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@keydown.escape.window="close()"
|
||||
>
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-linear duration-300"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-background/80 backdrop-blur-sm"
|
||||
@click="close()"
|
||||
></div>
|
||||
<!-- HTMX-driven Auth Modal Container -->
|
||||
{# This modal no longer manages form submission client-side. Forms are fetched
|
||||
and submitted via HTMX using the account views endpoints (CustomLoginView/CustomSignupView). #}
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="close()"
|
||||
class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<div id="auth-modal" class="fixed inset-0 z-50 hidden items-center justify-center" role="dialog" aria-modal="true" tabindex="-1" hx-on:keydown="if(event.key==='Escape'){ document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden'); }">
|
||||
<div id="auth-modal-overlay" class="fixed inset-0 bg-background/80 backdrop-blur-sm" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');"></div>
|
||||
|
||||
<div id="auth-modal-content" class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg" role="dialog" aria-modal="true">
|
||||
<button type="button" class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors auth-close" onclick="document.getElementById('auth-modal').classList.add('hidden'); document.body.classList.remove('overflow-hidden');">
|
||||
<i class="fas fa-times w-4 h-4"></i>
|
||||
</button>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div x-show="mode === 'login'" class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
||||
Sign In
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Enter your credentials to access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
||||
<template x-for="provider in socialProviders" :key="provider.id">
|
||||
<button
|
||||
@click="handleSocialLogin(provider.id)"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
||||
:class="{
|
||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="mr-2 w-4 h-4"
|
||||
:class="{
|
||||
'fab fa-google': provider.id === 'google',
|
||||
'fab fa-discord': provider.id === 'discord'
|
||||
}"
|
||||
></i>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="socialLoading" class="grid grid-cols-2 gap-4">
|
||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
||||
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-muted"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form
|
||||
@submit.prevent="handleLogin()"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label for="login-username" class="text-sm font-medium">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
x-model="loginForm.username"
|
||||
placeholder="Enter your email or username"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="login-password" class="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="loginForm.password"
|
||||
placeholder="Enter your password"
|
||||
class="input w-full pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="{% url 'account_reset_password' %}"
|
||||
class="text-sm text-primary hover:text-primary/80 underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div x-show="loginError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<span x-text="loginError"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
||||
>
|
||||
<span x-show="!loginLoading">Sign In</span>
|
||||
<span x-show="loginLoading" class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Signing in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch to Register -->
|
||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
||||
Don't have an account?
|
||||
<button
|
||||
@click="switchToRegister()"
|
||||
class="text-primary hover:underline font-medium ml-1"
|
||||
type="button"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div x-show="mode === 'register'" class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
|
||||
Create Account
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
Join ThrillWiki to start exploring theme parks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Registration Buttons -->
|
||||
<div x-show="socialProviders.length > 0" class="mb-6">
|
||||
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
|
||||
<template x-for="provider in socialProviders" :key="provider.id">
|
||||
<button
|
||||
@click="handleSocialLogin(provider.id)"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
|
||||
:class="{
|
||||
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
|
||||
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
|
||||
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="mr-2 w-4 h-4"
|
||||
:class="{
|
||||
'fab fa-google': provider.id === 'google',
|
||||
'fab fa-discord': provider.id === 'discord'
|
||||
}"
|
||||
></i>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-muted"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 text-muted-foreground">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<form
|
||||
@submit.prevent="handleRegister()"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="register-first-name" class="text-sm font-medium">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="register-first-name"
|
||||
type="text"
|
||||
x-model="registerForm.first_name"
|
||||
placeholder="First name"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="register-last-name" class="text-sm font-medium">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="register-last-name"
|
||||
type="text"
|
||||
x-model="registerForm.last_name"
|
||||
placeholder="Last name"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-email" class="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
type="email"
|
||||
x-model="registerForm.email"
|
||||
placeholder="Enter your email"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-username" class="text-sm font-medium">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="register-username"
|
||||
type="text"
|
||||
x-model="registerForm.username"
|
||||
placeholder="Choose a username"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-password" class="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="register-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="registerForm.password1"
|
||||
placeholder="Create a password"
|
||||
class="input w-full pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="register-password2" class="text-sm font-medium">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="register-password2"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
x-model="registerForm.password2"
|
||||
placeholder="Confirm your password"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div x-show="registerError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<span x-text="registerError"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="registerLoading"
|
||||
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
|
||||
>
|
||||
<span x-show="!registerLoading">Create Account</span>
|
||||
<span x-show="registerLoading" class="flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Creating account...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch to Login -->
|
||||
<div class="text-center text-sm text-muted-foreground mt-6">
|
||||
Already have an account?
|
||||
<button
|
||||
@click="switchToLogin()"
|
||||
class="text-primary hover:underline font-medium ml-1"
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content will be loaded here via HTMX -->
|
||||
<div id="auth-modal-body" hx-swap-oob="true" hx-on:htmx:afterSwap="(function(){ var el=document.querySelector('#auth-modal-body input, #auth-modal-body button'); if(el){ el.focus(); } })()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Example triggers (elsewhere in the app you can use hx-get to load the desired form into #auth-modal-body):
|
||||
<button hx-get="{% url 'account_login' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign in</button>
|
||||
<button hx-get="{% url 'account_signup' %}" hx-target="#auth-modal-body" hx-swap="innerHTML" onclick="document.getElementById('auth-modal').classList.remove('hidden')">Sign up</button>
|
||||
The login/signup views already return partials for HTMX requests (see `CustomLoginView` / `CustomSignupView`).
|
||||
#}
|
||||
|
||||
5
backend/templates/components/filters/active_filters.html
Normal file
5
backend/templates/components/filters/active_filters.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="active-filters">
|
||||
{% for f in active %}
|
||||
<span class="active-filter">{{ f.label }} <button hx-get="{{ f.remove_url }}">×</button></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" name="{{ name }}" value="{{ item.value }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||
<span>{{ item.label }} <small>({{ item.count }})</small></span>
|
||||
</label>
|
||||
8
backend/templates/components/filters/filter_group.html
Normal file
8
backend/templates/components/filters/filter_group.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<section class="filter-group" aria-expanded="true">
|
||||
<h4>{{ group.title }}</h4>
|
||||
<div class="filter-items">
|
||||
{% for item in group.items %}
|
||||
{% include "components/filters/filter_checkbox.html" with item=item %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
4
backend/templates/components/filters/filter_range.html
Normal file
4
backend/templates/components/filters/filter_range.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="filter-range">
|
||||
<label>{{ label }}</label>
|
||||
<input type="range" name="{{ name }}" min="{{ min }}" max="{{ max }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar" />
|
||||
</div>
|
||||
8
backend/templates/components/filters/filter_select.html
Normal file
8
backend/templates/components/filters/filter_select.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div class="filter-select">
|
||||
<label for="{{ name }}">{{ label }}</label>
|
||||
<select id="{{ name }}" name="{{ name }}" hx-get="{{ update_url }}" hx-include="#filter-sidebar">
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.value }}">{{ opt.label }} ({{ opt.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
6
backend/templates/components/filters/filter_sidebar.html
Normal file
6
backend/templates/components/filters/filter_sidebar.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<aside class="filter-sidebar" id="filter-sidebar">
|
||||
{% for group in groups %}
|
||||
{% include "components/filters/filter_group.html" with group=group %}
|
||||
{% endfor %}
|
||||
<div class="applied-filters">{% include "components/filters/active_filters.html" %}</div>
|
||||
</aside>
|
||||
7
backend/templates/components/inline_edit/edit_form.html
Normal file
7
backend/templates/components/inline_edit/edit_form.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<form hx-post="{{ action }}" hx-target="closest .editable-field" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</form>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="editable-field editable-field-{{ name }}">
|
||||
<div class="field-display">{% include "components/inline_edit/field_display.html" %}</div>
|
||||
<div class="field-edit" hx-swap-oob="true"></div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="field-display-value">{{ value }}</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
{# Global search removed duplicate header; primary header below handles search #}
|
||||
{% comment %}
|
||||
Enhanced Header Component - Matches React Frontend Design
|
||||
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
@@ -133,32 +134,29 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
|
||||
<!-- Desktop Right Side -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Enhanced Search -->
|
||||
<div class="relative" x-data="searchComponent()">
|
||||
<!-- Enhanced Search (HTMX-driven) -->
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search parks, rides..."
|
||||
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
hx-get="{% url 'search:search' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results"
|
||||
hx-include="this"
|
||||
hx-indicator=".htmx-loading-indicator"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
/>
|
||||
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
|
||||
</div>
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
|
||||
<!-- Search Results Dropdown: always present and controlled by HTMX swaps -->
|
||||
<div
|
||||
id="search-results"
|
||||
x-show="results.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||
aria-live="polite"
|
||||
>
|
||||
<!-- Search results will be populated by HTMX -->
|
||||
</div>
|
||||
@@ -239,13 +237,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
{% else %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="window.authModal.show('login')"
|
||||
hx-get="{% url 'account_login' %}"
|
||||
hx-target="#auth-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md px-3"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
@click="window.authModal.show('register')"
|
||||
hx-get="{% url 'account_signup' %}"
|
||||
hx-target="#auth-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('auth-modal').classList.remove('hidden'); document.body.classList.add('overflow-hidden');"
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
|
||||
>
|
||||
Sign Up
|
||||
|
||||
5
backend/templates/components/modals/modal_base.html
Normal file
5
backend/templates/components/modals/modal_base.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="modal-content">
|
||||
{% block modal_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
5
backend/templates/components/modals/modal_confirm.html
Normal file
5
backend/templates/components/modals/modal_confirm.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
{% include "htmx/components/confirm_dialog.html" %}
|
||||
{% endblock %}
|
||||
10
backend/templates/components/modals/modal_form.html
Normal file
10
backend/templates/components/modals/modal_form.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
<form hx-post="{{ action }}" hx-target="#modal-container" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
{% include "forms/partials/form_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
3
backend/templates/components/modals/modal_loading.html
Normal file
3
backend/templates/components/modals/modal_loading.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="modal-loading">
|
||||
{% include "htmx/components/loading_indicator.html" %}
|
||||
</div>
|
||||
10
backend/templates/core/search/partials/search_dropdown.html
Normal file
10
backend/templates/core/search/partials/search_dropdown.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<div id="search-dropdown" class="search-dropdown">
|
||||
{% include "core/search/partials/search_suggestions.html" %}
|
||||
<div id="search-results">
|
||||
{% for item in results %}
|
||||
{% include "core/search/partials/search_result_item.html" with item=item %}
|
||||
{% empty %}
|
||||
{% include "core/search/partials/search_empty.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
1
backend/templates/core/search/partials/search_empty.html
Normal file
1
backend/templates/core/search/partials/search_empty.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="search-empty">No results found.</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="search-result-item">
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
<div class="muted">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<ul class="search-suggestions">
|
||||
{% for suggestion in suggestions %}
|
||||
<li hx-get="{{ suggestion.url }}" hx-swap="#search-results">{{ suggestion.text }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
9
backend/templates/core/search/search.html
Normal file
9
backend/templates/core/search/search.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Search</h1>
|
||||
<form hx-get="/search/" hx-trigger="input changed delay:300ms" hx-target="#search-dropdown">
|
||||
<input name="q" placeholder="Search..." />
|
||||
</form>
|
||||
<div id="search-dropdown"></div>
|
||||
{% endblock %}
|
||||
7
backend/templates/forms/partials/field_error.html
Normal file
7
backend/templates/forms/partials/field_error.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% if errors %}
|
||||
<ul class="field-errors">
|
||||
{% for e in errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
1
backend/templates/forms/partials/field_success.html
Normal file
1
backend/templates/forms/partials/field_success.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="field-success" aria-hidden="true">✓</div>
|
||||
4
backend/templates/forms/partials/form_actions.html
Normal file
4
backend/templates/forms/partials/form_actions.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</div>
|
||||
5
backend/templates/forms/partials/form_field.html
Normal file
5
backend/templates/forms/partials/form_field.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="form-field" data-field-name="{{ field.name }}">
|
||||
<label for="id_{{ field.name }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
|
||||
</div>
|
||||
9
backend/templates/htmx/components/confirm_dialog.html
Normal file
9
backend/templates/htmx/components/confirm_dialog.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="htmx-confirm" role="dialog" aria-modal="true">
|
||||
<div class="confirm-body">
|
||||
<p>{{ message|default:"Are you sure?" }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button hx-post="{{ confirm_url }}" hx-vals='{"confirm": true}'>Confirm</button>
|
||||
<button hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
backend/templates/htmx/components/error_message.html
Normal file
4
backend/templates/htmx/components/error_message.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="htmx-error" role="alert" aria-live="assertive">
|
||||
<strong>{{ title|default:"Error" }}</strong>
|
||||
<p>{{ message|default:"An error occurred. Please try again." }}</p>
|
||||
</div>
|
||||
4
backend/templates/htmx/components/filter_badge.html
Normal file
4
backend/templates/htmx/components/filter_badge.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<span class="filter-badge" role="status">
|
||||
{{ label }}
|
||||
<button hx-get="{{ remove_url }}" hx-swap="outerHTML">×</button>
|
||||
</span>
|
||||
4
backend/templates/htmx/components/inline_edit_field.html
Normal file
4
backend/templates/htmx/components/inline_edit_field.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="inline-edit-field" data-editable-name="{{ name }}">
|
||||
<div class="display">{{ value }}</div>
|
||||
<button hx-get="{{ edit_url }}" hx-target="closest .inline-edit-field" hx-swap="outerHTML">Edit</button>
|
||||
</div>
|
||||
3
backend/templates/htmx/components/loading_indicator.html
Normal file
3
backend/templates/htmx/components/loading_indicator.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="htmx-loading-indicator" aria-hidden="true">
|
||||
<div class="spinner">Loading…</div>
|
||||
</div>
|
||||
9
backend/templates/htmx/components/pagination.html
Normal file
9
backend/templates/htmx/components/pagination.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<nav class="htmx-pagination" role="navigation" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}" hx-swap="#results">Previous</button>
|
||||
{% endif %}
|
||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}" hx-swap="#results">Next</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
3
backend/templates/htmx/components/success_toast.html
Normal file
3
backend/templates/htmx/components/success_toast.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="htmx-success" role="status" aria-live="polite">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
19
backend/templates/parks/partials/park_list_item.html
Normal file
19
backend/templates/parks/partials/park_list_item.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% for park in parks %}
|
||||
<div class="park-search-item p-2 border-b border-gray-100 flex items-center justify-between" data-park-id="{{ park.id }}">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ park.name }}</div>
|
||||
{% if park.location %}
|
||||
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<button class="px-2 py-1 text-sm bg-blue-600 text-white rounded"
|
||||
hx-post="{% url 'parks:htmx_add_park_to_trip' %}"
|
||||
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="afterbegin">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
21
backend/templates/parks/partials/saved_trips.html
Normal file
21
backend/templates/parks/partials/saved_trips.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="saved-trips-list">
|
||||
{% if trips %}
|
||||
<ul class="space-y-2">
|
||||
{% for trip in trips %}
|
||||
<li class="p-2 bg-white rounded-md shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">{{ trip.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ trip.created_at }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'parks:roadtrip_detail' trip.id %}" class="text-blue-600">View</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-gray-500">No saved trips yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
16
backend/templates/parks/partials/trip_park_item.html
Normal file
16
backend/templates/parks/partials/trip_park_item.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="trip-park-item draggable-item flex items-center justify-between p-2 bg-gray-50 rounded-md" data-park-id="{{ park.id }}">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-sm font-medium text-gray-900">{{ park.name }}</div>
|
||||
{% if park.location %}
|
||||
<div class="text-xs text-gray-500">{{ park.location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="px-2 py-1 text-sm text-red-600 hover:text-red-800"
|
||||
hx-post="{% url 'parks:htmx_remove_park_from_trip' %}"
|
||||
hx-vals='{"park_id": "{{ park.id }}"}'
|
||||
hx-swap="delete">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
34
backend/templates/parks/partials/trip_parks_list.html
Normal file
34
backend/templates/parks/partials/trip_parks_list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div id="trip-parks" class="space-y-2">
|
||||
{% if trip_parks %}
|
||||
{% for park in trip_parks %}
|
||||
{% include 'parks/partials/trip_park_item.html' with park=park %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div id="empty-trip" class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('htmx:load', function () {
|
||||
const el = document.getElementById('trip-parks');
|
||||
if (!el) return;
|
||||
if (el.dataset.sortableInit) return;
|
||||
if (typeof Sortable === 'undefined' || typeof htmx === 'undefined') return;
|
||||
el.dataset.sortableInit = '1';
|
||||
const sorter = new Sortable(el, {
|
||||
animation: 150,
|
||||
handle: '.draggable-item',
|
||||
onEnd: function () {
|
||||
const order = Array.from(el.querySelectorAll('[data-park-id]')).map(function (n) {
|
||||
return n.dataset.parkId;
|
||||
});
|
||||
htmx.ajax('POST', '{% url "parks:htmx_reorder_parks" %}', { values: { 'order[]': order } });
|
||||
},
|
||||
});
|
||||
el._tripSortable = sorter;
|
||||
});
|
||||
</script>
|
||||
21
backend/templates/parks/partials/trip_summary.html
Normal file
21
backend/templates/parks/partials/trip_summary.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div id="trip-summary" class="trip-summary-card">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900">Trip Summary</h3>
|
||||
<div class="trip-stats grid grid-cols-2 gap-4">
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-distance">{{ summary.total_distance }}</div>
|
||||
<div class="trip-stat-label">Total Miles</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-time">{{ summary.total_time }}</div>
|
||||
<div class="trip-stat-label">Drive Time</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-parks">{{ summary.total_parks }}</div>
|
||||
<div class="trip-stat-label">Parks</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-rides">{{ summary.total_rides }}</div>
|
||||
<div class="trip-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@
|
||||
<input type="text" id="park-search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#park-search-results"
|
||||
hx-indicator="#search-loading">
|
||||
@@ -166,18 +166,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<!-- Search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Itinerary -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onclick="tripPlanner.clearTrip()">
|
||||
hx-post="{% url 'parks:htmx_clear_trip' %}"
|
||||
hx-target="#trip-parks"
|
||||
hx-swap="innerHTML">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,15 +192,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
||||
hx-post="{% url 'parks:htmx_optimize_route' %}"
|
||||
hx-target="#trip-summary"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#trip-summary-loading">
|
||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.calculateRoute()" disabled>
|
||||
hx-post="{% url 'parks:htmx_calculate_route' %}"
|
||||
hx-target="#trip-summary"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#trip-summary-loading">
|
||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||
</button>
|
||||
</div>
|
||||
@@ -230,7 +238,10 @@
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
onclick="tripPlanner.saveTrip()">
|
||||
hx-post="{% url 'parks:htmx_save_trip' %}"
|
||||
hx-target="#saved-trips"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#trips-loading">
|
||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||
</button>
|
||||
</div>
|
||||
@@ -245,12 +256,12 @@
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.fitRoute()">
|
||||
onclick="(window.roadTripPlanner||{}).fitRoute()">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.toggleAllParks()">
|
||||
onclick="(window.roadTripPlanner||{}).toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||
</button>
|
||||
</div>
|
||||
@@ -306,483 +317,12 @@
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script src="{% static 'js/roadtrip.js' %}"></script>
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.loadAllParks();
|
||||
this.initDragDrop();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initMap() {
|
||||
// Initialize the map
|
||||
this.map = L.map('map-container', {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
async loadAllParks() {
|
||||
try {
|
||||
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.locations) {
|
||||
this.allParks = data.data.locations;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initDragDrop() {
|
||||
// Make trip parks sortable
|
||||
new Sortable(document.getElementById('trip-parks'), {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Handle park search results
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.target.id === 'park-search-results') {
|
||||
this.handleSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSearchResults() {
|
||||
const results = document.getElementById('park-search-results');
|
||||
if (results.children.length > 0) {
|
||||
results.classList.remove('hidden');
|
||||
} else {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addParkToTrip(parkData) {
|
||||
// Check if park already in trip
|
||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tripParks.push(parkData);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
// Hide search results
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
document.getElementById('park-search').value = '';
|
||||
}
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Clear existing parks (except empty state)
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.id !== 'empty-trip') {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const parkElement = this.createTripParkElement(park, index);
|
||||
container.appendChild(parkElement);
|
||||
});
|
||||
}
|
||||
|
||||
createTripParkElement(park, index) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'park-card draggable-item';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
||||
${index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
${park.name}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
${park.formatted_location || 'Location not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
updateTripMarkers() {
|
||||
// Clear existing trip markers
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.parkMarkers = {};
|
||||
|
||||
// Add markers for trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const marker = this.createTripMarker(park, index);
|
||||
this.parkMarkers[park.id] = marker;
|
||||
marker.addTo(this.map);
|
||||
});
|
||||
|
||||
// Fit map to show all trip parks
|
||||
if (this.tripParks.length > 0) {
|
||||
this.fitRoute();
|
||||
}
|
||||
}
|
||||
|
||||
createTripMarker(park, index) {
|
||||
let markerClass = 'waypoint-stop';
|
||||
if (index === 0) markerClass = 'waypoint-start';
|
||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `waypoint-marker ${markerClass}`,
|
||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
const popupContent = `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
||||
Remove from Trip
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
return marker;
|
||||
}
|
||||
|
||||
reorderTripParks(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
|
||||
// Clear route to force recalculation
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.tripParks.map(p => p.id);
|
||||
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ park_ids: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.optimized_order) {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedParks = data.optimized_order.map(id =>
|
||||
this.tripParks.find(p => p.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.tripParks = optimizedParks;
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Route optimization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
// Remove existing route
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
}
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: () => null, // Don't create default markers
|
||||
lineOptions: {
|
||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
|
||||
updateTripSummary(route) {
|
||||
if (!route) return;
|
||||
|
||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-distance').textContent = totalDistance;
|
||||
document.getElementById('total-time').textContent = totalTime;
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = totalRides;
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// Implementation for showing/hiding all parks on the map
|
||||
const button = document.getElementById('toggle-parks');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (this.showingAllParks) {
|
||||
// Hide all parks
|
||||
this.showingAllParks = false;
|
||||
icon.className = 'mr-1 fas fa-eye';
|
||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
||||
} else {
|
||||
// Show all parks
|
||||
this.showingAllParks = true;
|
||||
icon.className = 'mr-1 fas fa-eye-slash';
|
||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
||||
this.displayAllParks();
|
||||
}
|
||||
}
|
||||
|
||||
displayAllParks() {
|
||||
// Add markers for all parks (implementation depends on requirements)
|
||||
this.allParks.forEach(park => {
|
||||
if (!this.parkMarkers[park.id]) {
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'location-marker location-marker-park',
|
||||
html: '<div class="location-marker-inner">🎢</div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.parkMarkers[`all_${park.id}`] = marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const calculateBtn = document.getElementById('calculate-route');
|
||||
|
||||
const hasEnoughParks = this.tripParks.length >= 2;
|
||||
|
||||
optimizeBtn.disabled = !hasEnoughParks;
|
||||
calculateBtn.disabled = !hasEnoughParks;
|
||||
}
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
}
|
||||
|
||||
async saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripName = prompt('Enter a name for this trip:');
|
||||
if (!tripName) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tripName,
|
||||
park_ids: this.tripParks.map(p => p.id)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Trip saved successfully!');
|
||||
// Refresh saved trips
|
||||
htmx.trigger('#saved-trips', 'refresh');
|
||||
} else {
|
||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save trip failed:', error);
|
||||
alert('Failed to save trip');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for adding parks from search results
|
||||
window.addParkToTrip = function(parkData) {
|
||||
window.tripPlanner.addParkToTrip(parkData);
|
||||
};
|
||||
|
||||
// Initialize trip planner when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
if (globalThis.RoadtripMap) {
|
||||
globalThis.RoadtripMap.init('map-container');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user