feat: complete monorepo structure with frontend and shared resources

- Add complete backend/ directory with full Django application
- Add frontend/ directory with Vite + TypeScript setup ready for Next.js
- Add comprehensive shared/ directory with:
  - Complete documentation and memory-bank archives
  - Media files and avatars (letters, park/ride images)
  - Deployment scripts and automation tools
  - Shared types and utilities
- Add architecture/ directory with migration guides
- Configure pnpm workspace for monorepo development
- Update .gitignore to exclude .django_tailwind_cli/ build artifacts
- Preserve all historical documentation in shared/docs/memory-bank/
- Set up proper structure for full-stack development with shared resources
This commit is contained in:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
{% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}" class="btn-primary">
<i class="mr-2 fas fa-plus"></i>Add Park
</a>
{% endif %}

View File

@@ -0,0 +1,356 @@
{% load static %}
<style>
/* Ensure map container and its elements stay below other UI elements */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
z-index: 1 !important;
}
.leaflet-control {
z-index: 2 !important;
}
</style>
<div class="location-widget" id="locationWidget">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
id="locationSearch"
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div id="searchResults"
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
</div>
</div>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
</div>
{# Location Form Fields #}
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address
</label>
<input type="text"
name="street_address"
id="streetAddress"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.street_address.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<input type="text"
name="city"
id="city"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.city.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
State/Region
</label>
<input type="text"
name="state"
id="state"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.state.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Country
</label>
<input type="text"
name="country"
id="country"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.country.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Postal Code
</label>
<input type="text"
name="postal_code"
id="postalCode"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.postal_code.value|default:'' }}">
</div>
</div>
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}">
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let map = null;
let marker = null;
const searchInput = document.getElementById('locationSearch');
const searchResults = document.getElementById('searchResults');
let searchTimeout;
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
try {
// Convert to string-3 with exact decimal places
const rounded = Number(value).toFixed(decimalPlaces);
// Convert to string-3 without decimal point for digit counting
const strValue = rounded.replace('.', '').replace('-', '');
// Remove trailing zeros
const strippedValue = strValue.replace(/0+$/, '');
// If total digits exceed maxDigits, round further
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
// Return the string-3 representation to preserve exact decimal places
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
}
function validateCoordinates(lat, lng) {
// Normalize coordinates
const normalizedLat = normalizeCoordinate(lat, 9, 6);
const normalizedLng = normalizeCoordinate(lng, 10, 6);
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
}
// Initialize map
function initMap() {
map = L.map('locationMap').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Initialize with existing coordinates if available
const initialLat = document.getElementById('latitude').value;
const initialLng = document.getElementById('longitude').value;
if (initialLat && initialLng) {
try {
const normalized = validateCoordinates(initialLat, initialLng);
addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
}
}
// Handle map clicks
map.on('click', async function(e) {
try {
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
if (!response.ok) {
throw new Error('Geocoding request failed');
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
updateLocation(normalized.lat, normalized.lng, data);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
});
}
// Initialize map
initMap();
// Handle location search
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (!query) {
searchResults.classList.add('hidden');
return;
}
searchTimeout = setTimeout(async function() {
try {
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search request failed');
}
const data = await response.json();
if (data.results && data.results.length > 0) {
const resultsHtml = data.results.map((result, index) => `
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
data-result-index="${index}">
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
${[
result.street,
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
result.state || (result.address && (result.address.state || result.address.region)),
result.country || (result.address && result.address.country),
result.postal_code || (result.address && result.address.postcode)
].filter(Boolean).join(', ')}
</div>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
searchResults.classList.remove('hidden');
// Store results data
searchResults.dataset.results = JSON.stringify(data.results);
// Add click handlers
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
el.addEventListener('click', function() {
const results = JSON.parse(searchResults.dataset.results);
const result = results[this.dataset.resultIndex];
selectLocation(result);
});
});
} else {
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
searchResults.classList.remove('hidden');
}
} catch (error) {
console.error('Search failed:', error);
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
}
}, 300);
});
// Hide search results when clicking outside
document.addEventListener('click', function(e) {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.classList.add('hidden');
}
});
function addMarker(lat, lng) {
if (marker) {
marker.remove();
}
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 13);
}
function updateLocation(lat, lng, data) {
try {
const normalized = validateCoordinates(lat, lng);
// Update coordinates
document.getElementById('latitude').value = normalized.lat;
document.getElementById('longitude').value = normalized.lng;
// Update marker
addMarker(normalized.lat, normalized.lng);
// Update form fields with English names where available
const address = data.address || {};
document.getElementById('streetAddress').value =
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
document.getElementById('city').value =
address.city || address.town || address.village || '';
document.getElementById('state').value =
address.state || address.region || '';
document.getElementById('country').value = address.country || '';
document.getElementById('postalCode').value = address.postcode || '';
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
}
function selectLocation(result) {
if (!result) return;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.house_number || (result.address && result.address.house_number) || '',
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
country: result.country || (result.address && result.address.country) || '',
postcode: result.postal_code || (result.address && result.address.postcode) || ''
}
};
updateLocation(normalized.lat, normalized.lng, address);
searchResults.classList.add('hidden');
searchInput.value = address.name;
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
}
// Add form submit handler
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
const lat = document.getElementById('latitude').value;
const lng = document.getElementById('longitude').value;
if (lat && lng) {
try {
validateCoordinates(lat, lng);
} catch (error) {
e.preventDefault();
alert(error.message || 'Invalid coordinates. Please check the location.');
}
}
});
});
</script>

View File

@@ -0,0 +1,37 @@
{% load park_tags %}
{% if user.is_authenticated %}
<div class="flex justify-end gap-2 mb-2">
<a href="{% url 'parks:park_update' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit
</a>
{% include "rides/partials/add_ride_modal.html" %}
{% if perms.media.add_photo %}
<button class="transition-transform btn-secondary hover:scale-105"
@click="$dispatch('show-photo-upload')">
<i class="mr-1 fas fa-camera"></i>Upload Photo
</button>
{% endif %}
<!-- Add/Edit Review Button -->
{% if not park.reviews.exists %}
<a href="{% url 'reviews:add_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Add Review
</a>
{% else %}
{% if user|has_reviewed_park:park %}
<a href="{% url 'reviews:edit_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Edit Review
</a>
{% else %}
<a href="{% url 'reviews:add_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Add Review
</a>
{% endif %}
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,76 @@
<!-- Parks Grid -->
<div class="grid-adaptive">
{% for park in parks %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
{% if park.photos.exists %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="object-cover w-full">
</div>
{% endif %}
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ park.name }}
</a>
</h2>
{% if park.city or park.state or park.country %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
<i class="mr-1 fas fa-map-marker-alt"></i>
{% spaceless %}
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
</p>
{% endspaceless %}
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if park.operator %}
<div class="mt-4 text-sm text-blue-600 dark:text-blue-400">
{{ park.operator.name }}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-xs">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
{% if parks %}
{% for park in parks %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
{{ park.name }}
</button>
{% endfor %}
{% else %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
{% if search_term %}
No parks found
{% else %}
Start typing to search...
{% endif %}
</div>
{% endif %}
</div>
<script>
function selectPark(id, name) {
document.getElementById('id_park').value = id;
document.getElementById('id_park_search').value = name;
document.getElementById('park-search-results').innerHTML = '';
}
</script>