mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:51:09 -05:00
35 KiB
35 KiB
Django Template & Frontend Architecture Analysis
Date: January 7, 2025
Analyst: Roo (Architect Mode)
Purpose: Django template system and frontend architecture analysis for Symfony conversion
Status: Complete frontend layer analysis for conversion planning
Overview
This document analyzes the Django template system, static asset management, HTMX integration, and frontend architecture to facilitate conversion to Symfony's Twig templating system and modern frontend tooling.
Template System Architecture
Django Template Structure
templates/
├── base/
│ ├── base.html # Main layout
│ ├── header.html # Site header
│ ├── footer.html # Site footer
│ └── navigation.html # Main navigation
├── account/
│ ├── login.html # Authentication
│ ├── signup.html
│ └── partials/
│ ├── login_form.html # HTMX login modal
│ └── signup_form.html # HTMX signup modal
├── parks/
│ ├── list.html # Park listing
│ ├── detail.html # Park detail page
│ ├── form.html # Park edit form
│ └── partials/
│ ├── park_card.html # HTMX park card
│ ├── park_grid.html # HTMX park grid
│ ├── rides_section.html # HTMX rides tab
│ └── photos_section.html # HTMX photos tab
├── rides/
│ ├── list.html
│ ├── detail.html
│ └── partials/
│ ├── ride_card.html
│ ├── ride_stats.html
│ └── ride_photos.html
├── search/
│ ├── index.html
│ ├── results.html
│ └── partials/
│ ├── suggestions.html # HTMX autocomplete
│ ├── filters.html # HTMX filter controls
│ └── results_grid.html # HTMX results
└── moderation/
├── dashboard.html
├── submissions.html
└── partials/
├── submission_card.html
└── approval_form.html
Base Template Analysis
Main Layout Template
<!-- templates/base/base.html -->
<!DOCTYPE html>
<html lang="en" data-theme="{{ user.theme_preference|default:'auto' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ThrillWiki{% endblock %}</title>
<!-- SEO Meta Tags -->
<meta name="description" content="{% block description %}The ultimate theme park and roller coaster database{% endblock %}">
<meta name="keywords" content="{% block keywords %}theme parks, roller coasters, rides{% endblock %}">
<!-- Open Graph -->
<meta property="og:title" content="{% block og_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{% block description %}{% endblock %}{% endblock %}">
<meta property="og:image" content="{% block og_image %}{% static 'images/og-default.jpg' %}{% endblock %}">
<!-- Tailwind CSS -->
<link href="{% static 'css/styles.css' %}" rel="stylesheet">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<!-- Navigation -->
{% include 'base/navigation.html' %}
<!-- Main Content -->
<main class="container mx-auto px-4 py-8">
<!-- Messages -->
{% if messages %}
<div id="messages" class="mb-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} mb-2"
x-data="{ show: true }"
x-show="show"
x-transition>
{{ message }}
<button @click="show = false" class="ml-2">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<!-- Footer -->
{% include 'base/footer.html' %}
<!-- HTMX Configuration -->
<script>
// HTMX configuration
htmx.config.defaultSwapStyle = 'innerHTML';
htmx.config.scrollBehavior = 'smooth';
// CSRF token for HTMX
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
Navigation Component
<!-- templates/base/navigation.html -->
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
x-data="{ mobileOpen: false, userMenuOpen: false }">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center py-4">
<!-- Logo -->
<a href="{% url 'home' %}" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
ThrillWiki
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex space-x-8">
<a href="{% url 'park-list' %}"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Parks
</a>
<a href="{% url 'ride-list' %}"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Rides
</a>
<a href="{% url 'search' %}"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Search
</a>
{% if can_moderate %}
<a href="{% url 'moderation-dashboard' %}"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Moderation
{% if pending_submissions_count > 0 %}
<span class="bg-red-500 text-white rounded-full px-2 py-1 text-xs ml-1">
{{ pending_submissions_count }}
</span>
{% endif %}
</a>
{% endif %}
</div>
<!-- Search Bar -->
<div class="hidden md:block flex-1 max-w-md mx-8">
<input type="text"
name="q"
placeholder="Search parks, rides..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700"
hx-get="{% url 'search-suggestions' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-suggestions"
hx-indicator="#search-loading">
<div id="search-suggestions" class="relative"></div>
<div id="search-loading" class="htmx-indicator">Searching...</div>
</div>
<!-- User Menu -->
<div class="relative" x-data="{ open: false }">
{% if user.is_authenticated %}
<button @click="open = !open"
class="flex items-center space-x-2 hover:text-blue-600 dark:hover:text-blue-400">
{% if user.userprofile.avatar %}
<img src="{{ user.userprofile.avatar.url }}"
alt="{{ user.username }}"
class="w-8 h-8 rounded-full">
{% else %}
<div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white">
{{ user.username|first|upper }}
</div>
{% endif %}
<span>{{ user.username }}</span>
</button>
<div x-show="open"
@click.away="open = false"
x-transition
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-2">
<a href="{% url 'profile' user.username %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700">
Profile
</a>
<a href="{% url 'account_logout' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700">
Logout
</a>
</div>
{% else %}
<div class="space-x-4">
<button hx-get="{% url 'account_login' %}"
hx-target="#auth-modal"
hx-swap="innerHTML"
class="text-blue-600 dark:text-blue-400 hover:underline">
Login
</button>
<button hx-get="{% url 'account_signup' %}"
hx-target="#auth-modal"
hx-swap="innerHTML"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
Sign Up
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Auth Modal Container -->
<div id="auth-modal"></div>
</nav>
HTMX Integration Patterns
Autocomplete Component
<!-- templates/search/partials/suggestions.html -->
<div class="absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-b-lg shadow-lg z-50">
{% if results.parks or results.rides %}
{% if results.parks %}
<div class="border-b border-gray-200 dark:border-gray-700">
<div class="px-4 py-2 text-sm font-semibold text-gray-500 dark:text-gray-400">Parks</div>
{% for park in results.parks %}
<a href="{% url 'park-detail' park.slug %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="font-medium">{{ park.name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ park.operator.name }} • {{ park.status|title }}
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% if results.rides %}
<div>
<div class="px-4 py-2 text-sm font-semibold text-gray-500 dark:text-gray-400">Rides</div>
{% for ride in results.rides %}
<a href="{% url 'ride-detail' ride.slug %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="font-medium">{{ ride.name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ ride.park.name }} • {{ ride.get_ride_type_display }}
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="px-4 py-2 text-gray-500 dark:text-gray-400">
No results found for "{{ query }}"
</div>
{% endif %}
</div>
Dynamic Content Loading
<!-- templates/parks/partials/rides_section.html -->
<div id="park-rides-section">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">Rides ({{ rides.count }})</h2>
{% if can_edit %}
<button hx-get="{% url 'ride-create' %}?park={{ park.slug }}"
hx-target="#ride-form-modal"
hx-swap="innerHTML"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
Add Ride
</button>
{% endif %}
</div>
<!-- Filter Controls -->
<div class="mb-6" x-data="{ filterOpen: false }">
<button @click="filterOpen = !filterOpen"
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<span>Filters</span>
<svg class="w-4 h-4 transform transition-transform" :class="{ 'rotate-180': filterOpen }">
<!-- Chevron down icon -->
</svg>
</button>
<div x-show="filterOpen" x-transition class="mt-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<form hx-get="{% url 'park-rides-partial' park.slug %}"
hx-target="#rides-grid"
hx-trigger="change"
class="grid grid-cols-1 md:grid-cols-3 gap-4">
<select name="ride_type" class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700">
<option value="">All Types</option>
{% for value, label in ride_type_choices %}
<option value="{{ value }}" {% if request.GET.ride_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<select name="status" class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700">
<option value="">All Statuses</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<select name="sort" class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700">
<option value="name">Name A-Z</option>
<option value="-name">Name Z-A</option>
<option value="opening_date">Oldest First</option>
<option value="-opening_date">Newest First</option>
</select>
</form>
</div>
</div>
<!-- Rides Grid -->
<div id="rides-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for ride in rides %}
{% include 'rides/partials/ride_card.html' with ride=ride %}
{% endfor %}
</div>
<!-- Load More -->
{% if has_next_page %}
<div class="text-center mt-8">
<button hx-get="{% url 'park-rides-partial' park.slug %}?page={{ page_number|add:1 }}"
hx-target="#rides-grid"
hx-swap="beforeend"
class="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700">
Load More Rides
</button>
</div>
{% endif %}
</div>
<!-- Modal Container -->
<div id="ride-form-modal"></div>
Form Integration with HTMX
Dynamic Form Handling
<!-- templates/parks/partials/form.html -->
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
x-data="{ show: true }"
x-show="show"
x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">
{% if park %}Edit Park{% else %}Add Park{% endif %}
</h2>
<button @click="show = false"
hx-get=""
hx-target="#park-form-modal"
hx-swap="innerHTML"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
×
</button>
</div>
<form hx-post="{% if park %}{% url 'park-edit' park.slug %}{% else %}{% url 'park-create' %}{% endif %}"
hx-target="#park-form-modal"
hx-swap="innerHTML"
class="space-y-6">
{% csrf_token %}
<!-- Name Field -->
<div>
<label for="{{ form.name.id_for_label }}" class="block text-sm font-medium mb-2">
{{ form.name.label }}
</label>
{{ form.name }}
{% if form.name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.name.errors.0 }}
</div>
{% endif %}
</div>
<!-- Description Field -->
<div>
<label for="{{ form.description.id_for_label }}" class="block text-sm font-medium mb-2">
{{ form.description.label }}
</label>
{{ form.description }}
{% if form.description.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.description.errors.0 }}
</div>
{% endif %}
</div>
<!-- Operator Field with Autocomplete -->
<div>
<label for="{{ form.operator.id_for_label }}" class="block text-sm font-medium mb-2">
{{ form.operator.label }}
</label>
<input type="text"
name="operator_search"
placeholder="Search operators..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700"
hx-get="{% url 'ac-operators' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#operator-suggestions"
autocomplete="off">
<div id="operator-suggestions" class="relative"></div>
{{ form.operator.as_hidden }}
{% if form.operator.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.operator.errors.0 }}
</div>
{% endif %}
</div>
<!-- Submit Buttons -->
<div class="flex space-x-4">
<button type="submit"
class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500">
{% if park %}Update Park{% else %}Create Park{% endif %}
</button>
<button type="button"
@click="show = false"
hx-get=""
hx-target="#park-form-modal"
hx-swap="innerHTML"
class="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700">
Cancel
</button>
</div>
</form>
</div>
</div>
Static Asset Management
Tailwind CSS Configuration
// tailwind.config.js
module.exports = {
content: [
'./templates/**/*.html',
'./*/templates/**/*.html',
'./static/js/**/*.js',
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
900: '#1e3a8a',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
}
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}
Static Files Structure
static/
├── css/
│ ├── src/
│ │ ├── main.css # Tailwind source
│ │ ├── components.css # Custom components
│ │ └── utilities.css # Custom utilities
│ └── styles.css # Compiled output
├── js/
│ ├── main.js # Main JavaScript
│ ├── components/
│ │ ├── autocomplete.js # Autocomplete functionality
│ │ ├── modal.js # Modal management
│ │ └── theme-toggle.js # Dark mode toggle
│ └── vendor/
│ ├── htmx.min.js # HTMX library
│ └── alpine.min.js # Alpine.js library
└── images/
├── placeholders/
│ ├── park-placeholder.jpg
│ └── ride-placeholder.jpg
└── icons/
├── logo.svg
└── social-icons/
Custom CSS Components
/* static/css/src/components.css */
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2;
}
.btn-primary {
@apply btn bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
}
.btn-secondary {
@apply btn bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500;
}
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700;
}
.card-header {
@apply px-6 py-4 border-b border-gray-200 dark:border-gray-700;
}
.card-body {
@apply px-6 py-4;
}
.form-input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100;
}
.alert {
@apply px-4 py-3 rounded-lg border;
}
.alert-success {
@apply alert bg-green-50 border-green-200 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200;
}
.alert-error {
@apply alert bg-red-50 border-red-200 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200;
}
.htmx-indicator {
@apply opacity-0 transition-opacity;
}
.htmx-request .htmx-indicator {
@apply opacity-100;
}
.htmx-request.htmx-indicator {
@apply opacity-100;
}
}
JavaScript Architecture
HTMX Configuration
// static/js/main.js
document.addEventListener('DOMContentLoaded', function() {
// HTMX Global Configuration
htmx.config.defaultSwapStyle = 'innerHTML';
htmx.config.scrollBehavior = 'smooth';
htmx.config.requestClass = 'htmx-request';
htmx.config.addedClass = 'htmx-added';
htmx.config.settledClass = 'htmx-settled';
// Global HTMX event handlers
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCSRFToken();
evt.detail.headers['X-Requested-With'] = 'XMLHttpRequest';
});
document.body.addEventListener('htmx:beforeSwap', function(evt) {
// Handle error responses
if (evt.detail.xhr.status === 400) {
// Keep form visible to show validation errors
evt.detail.shouldSwap = true;
} else if (evt.detail.xhr.status === 403) {
// Show permission denied message
showAlert('Permission denied', 'error');
evt.detail.shouldSwap = false;
} else if (evt.detail.xhr.status >= 500) {
// Show server error message
showAlert('Server error occurred', 'error');
evt.detail.shouldSwap = false;
}
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
// Re-initialize any JavaScript components in swapped content
initializeComponents(evt.detail.target);
});
// Initialize components on page load
initializeComponents(document);
});
function getCSRFToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content');
}
function initializeComponents(container) {
// Initialize any JavaScript components that need setup
container.querySelectorAll('[data-component]').forEach(el => {
const component = el.dataset.component;
if (window.components && window.components[component]) {
window.components[component](el);
}
});
}
function showAlert(message, type = 'info') {
const alertContainer = document.getElementById('messages') || createAlertContainer();
const alert = document.createElement('div');
alert.className = `alert alert-${type} mb-2 animate-fade-in`;
alert.innerHTML = `
${message}
<button onclick="this.parentElement.remove()" class="ml-2 hover:text-opacity-75">×</button>
`;
alertContainer.appendChild(alert);
// Auto-remove after 5 seconds
setTimeout(() => {
if (alert.parentElement) {
alert.remove();
}
}, 5000);
}
Component System
// static/js/components/autocomplete.js
window.components = window.components || {};
window.components.autocomplete = function(element) {
const input = element.querySelector('input');
const resultsContainer = element.querySelector('.autocomplete-results');
let currentFocus = -1;
input.addEventListener('keydown', function(e) {
const items = resultsContainer.querySelectorAll('.autocomplete-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
currentFocus = Math.min(currentFocus + 1, items.length - 1);
updateActiveItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentFocus = Math.max(currentFocus - 1, -1);
updateActiveItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (currentFocus >= 0 && items[currentFocus]) {
items[currentFocus].click();
}
} else if (e.key === 'Escape') {
resultsContainer.innerHTML = '';
currentFocus = -1;
}
});
function updateActiveItem(items) {
items.forEach((item, index) => {
item.classList.toggle('bg-blue-50', index === currentFocus);
});
}
};
Template Tags and Filters
Custom Template Tags
# parks/templatetags/parks_tags.py
from django import template
from django.utils.html import format_html
from django.urls import reverse
register = template.Library()
@register.simple_tag
def ride_type_icon(ride_type):
"""Return icon class for ride type"""
icons = {
'RC': 'fas fa-roller-coaster',
'DR': 'fas fa-ghost',
'FR': 'fas fa-circle',
'WR': 'fas fa-water',
'TR': 'fas fa-train',
'OT': 'fas fa-star',
}
return icons.get(ride_type, 'fas fa-question')
@register.simple_tag
def status_badge(status):
"""Return colored badge for status"""
colors = {
'OPERATING': 'bg-green-100 text-green-800',
'CLOSED_TEMP': 'bg-yellow-100 text-yellow-800',
'CLOSED_PERM': 'bg-red-100 text-red-800',
'UNDER_CONSTRUCTION': 'bg-blue-100 text-blue-800',
'DEMOLISHED': 'bg-gray-100 text-gray-800',
'RELOCATED': 'bg-purple-100 text-purple-800',
}
color_class = colors.get(status, 'bg-gray-100 text-gray-800')
display_text = status.replace('_', ' ').title()
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {}">{}</span>',
color_class,
display_text
)
@register.inclusion_tag('parks/partials/ride_card.html')
def ride_card(ride, show_park=False):
"""Render a ride card component"""
return {
'ride': ride,
'show_park': show_park,
}
@register.filter
def duration_format(seconds):
"""Format duration in seconds to human readable"""
if not seconds:
return ''
minutes = seconds // 60
remaining_seconds = seconds % 60
if minutes > 0:
return f"{minutes}:{remaining_seconds:02d}"
else:
return f"{seconds}s"
Conversion to Symfony Twig
Template Structure Mapping
| Django Template | Symfony Twig Equivalent |
|---|---|
templates/base/base.html |
templates/base.html.twig |
{% extends 'base.html' %} |
{% extends 'base.html.twig' %} |
{% block content %} |
{% block content %} |
{% include 'partial.html' %} |
{% include 'partial.html.twig' %} |
{% url 'route-name' %} |
{{ path('route_name') }} |
{% static 'file.css' %} |
{{ asset('file.css') }} |
{% csrf_token %} |
{{ csrf_token() }} |
{% if user.is_authenticated %} |
{% if is_granted('ROLE_USER') %} |
Twig Template Example
{# templates/parks/detail.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2">
<div class="card">
<div class="card-header">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold">{{ park.name }}</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Operated by
<a href="{{ path('operator_detail', {slug: park.operator.slug}) }}"
class="text-blue-600 hover:underline">
{{ park.operator.name }}
</a>
</p>
</div>
{{ status_badge(park.status) }}
</div>
</div>
<div class="card-body">
{% if park.description %}
<p class="text-gray-700 dark:text-gray-300 mb-6">
{{ park.description }}
</p>
{% endif %}
<!-- Tabs -->
<div x-data="{ activeTab: 'rides' }" class="mt-8">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8">
<button @click="activeTab = 'rides'"
:class="{ 'border-blue-500 text-blue-600': activeTab === 'rides' }"
class="py-2 px-1 border-b-2 border-transparent font-medium text-sm hover:text-gray-700 hover:border-gray-300">
Rides ({{ park.rides|length }})
</button>
<button @click="activeTab = 'photos'"
:class="{ 'border-blue-500 text-blue-600': activeTab === 'photos' }"
class="py-2 px-1 border-b-2 border-transparent font-medium text-sm hover:text-gray-700 hover:border-gray-300">
Photos ({{ park.photos|length }})
</button>
<button @click="activeTab = 'reviews'"
:class="{ 'border-blue-500 text-blue-600': activeTab === 'reviews' }"
class="py-2 px-1 border-b-2 border-transparent font-medium text-sm hover:text-gray-700 hover:border-gray-300">
Reviews ({{ park.reviews|length }})
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="mt-6">
<div x-show="activeTab === 'rides'"
hx-get="{{ path('park_rides_partial', {slug: park.slug}) }}"
hx-trigger="revealed once">
Loading rides...
</div>
<div x-show="activeTab === 'photos'"
hx-get="{{ path('park_photos_partial', {slug: park.slug}) }}"
hx-trigger="revealed once">
Loading photos...
</div>
<div x-show="activeTab === 'reviews'"
hx-get="{{ path('park_reviews_partial', {slug: park.slug}) }}"
hx-trigger="revealed once">
Loading reviews...
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
{% include 'parks/partials/park_info.html.twig' %}
{% include 'parks/partials/park_stats.html.twig' %}
</div>
</div>
</div>
{% endblock %}
Asset Management Migration
Symfony Asset Strategy
# webpack.config.js (Symfony Webpack Encore)
const Encore = require('@symfony/webpack-encore');
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
.addEntry('app', './assets/app.js')
.addEntry('admin', './assets/admin.js')
.addStyleEntry('styles', './assets/styles/app.css')
// Enable PostCSS for Tailwind
.enablePostCssLoader()
// Enable source maps in dev
.enableSourceMaps(!Encore.isProduction())
// Enable versioning in production
.enableVersioning(Encore.isProduction())
// Configure Babel
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = 3;
})
// Copy static assets
.copyFiles({
from: './assets/images',
to: 'images/[path][name].[hash:8].[ext]'
});
module.exports = Encore.getWebpackConfig();
Next Steps for Frontend Conversion
- Template Migration - Convert Django templates to Twig syntax
- Asset Pipeline - Set up Symfony Webpack Encore with Tailwind
- HTMX Integration - Ensure HTMX works with Symfony controllers
- Component System - Migrate JavaScript components to work with Twig
- Styling Migration - Adapt Tailwind configuration for Symfony structure
- Template Functions - Create Twig extensions for custom template tags
- Form Theming - Set up Symfony form themes to match current styling
Status: ✅ COMPLETED - Frontend architecture analysis for Symfony conversion
Next: Database schema analysis and migration planning