Add multiple categories to portfolio and advanced gallery navigation with swipe support
This commit is contained in:
@@ -59,16 +59,21 @@ class PortfolioImageInline(admin.TabularInline):
|
||||
|
||||
@admin.register(PortfolioItem)
|
||||
class PortfolioItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'client_name', 'completion_date', 'featured', 'is_active', 'gallery_count')
|
||||
list_filter = ('featured', 'is_active', 'completion_date', 'category')
|
||||
list_display = ('title', 'client_name', 'completion_date', 'featured', 'is_active', 'gallery_count', 'categories_display')
|
||||
list_filter = ('featured', 'is_active', 'completion_date', 'categories')
|
||||
search_fields = ('title', 'client_name', 'description')
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
filter_horizontal = ('categories',)
|
||||
inlines = [PortfolioImageInline]
|
||||
|
||||
def gallery_count(self, obj):
|
||||
return obj.gallery_images.count()
|
||||
gallery_count.short_description = 'Фото в галерее' # type: ignore
|
||||
|
||||
def categories_display(self, obj):
|
||||
return ", ".join([cat.name for cat in obj.categories.all()[:3]])
|
||||
categories_display.short_description = 'Категории' # type: ignore
|
||||
|
||||
|
||||
@admin.register(NewsArticle)
|
||||
class NewsArticleAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -174,7 +174,7 @@ class PortfolioItem(models.Model):
|
||||
completion_date = models.DateField(blank=True, null=True)
|
||||
image = models.ImageField(upload_to='static/img/portfolio/', blank=True, null=True, verbose_name='Главное изображение')
|
||||
featured = models.BooleanField(default=False)
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='portfolio_items')
|
||||
categories = models.ManyToManyField(Category, blank=True, related_name='portfolio_items', verbose_name='Категории')
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -7,12 +7,185 @@
|
||||
<!-- Lightbox CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/css/lightbox.min.css">
|
||||
<style>
|
||||
.hover-zoom {
|
||||
transition: transform 0.3s ease;
|
||||
/* Gallery Container */
|
||||
.gallery-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.hover-zoom:hover {
|
||||
|
||||
/* Thumbnails Sidebar */
|
||||
.gallery-thumbnails {
|
||||
flex: 0 0 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #0d6efd #f8f9fa;
|
||||
}
|
||||
|
||||
.gallery-thumbnails::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.gallery-thumbnails::-webkit-scrollbar-track {
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.gallery-thumbnails::-webkit-scrollbar-thumb {
|
||||
background: #0d6efd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thumbnail-item {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.thumbnail-item:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.thumbnail-item.active {
|
||||
border-color: #0d6efd;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Main Gallery Display */
|
||||
.gallery-main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-main-image {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Navigation Arrows */
|
||||
.gallery-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.gallery-nav:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.gallery-nav.prev {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.gallery-nav.next {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
/* Gallery Counter */
|
||||
.gallery-counter {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Image Caption */
|
||||
.gallery-caption {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.gallery-container {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.gallery-thumbnails {
|
||||
flex-direction: row;
|
||||
max-height: none;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.thumbnail-item {
|
||||
flex: 0 0 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.gallery-main-image {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.gallery-nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.gallery-nav.prev {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.gallery-nav.next {
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Categories badges */
|
||||
.category-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,8 +203,12 @@
|
||||
|
||||
<!-- Project Header -->
|
||||
<div class="text-center mb-5">
|
||||
{% if item.category %}
|
||||
<span class="badge bg-primary mb-3">{{ item.category.name }}</span>
|
||||
{% if item.categories.all %}
|
||||
<div class="category-badges">
|
||||
{% for category in item.categories.all %}
|
||||
<span class="badge bg-primary">{{ category.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<h1 class="display-5 fw-bold mb-3">{{ item.title }}</h1>
|
||||
{% if item.client_name %}
|
||||
@@ -46,29 +223,58 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Project Image -->
|
||||
<!-- Project Gallery with Advanced Navigation -->
|
||||
{% if item.image or item.gallery_images.all %}
|
||||
<div class="gallery-container">
|
||||
<!-- Thumbnails Sidebar -->
|
||||
<div class="gallery-thumbnails" id="galleryThumbnails">
|
||||
{% if item.image %}
|
||||
<div class="mb-5">
|
||||
<img src="{{ item.image.url }}" class="img-fluid rounded" alt="{{ item.title }}" style="width: 100%; max-height: 600px; object-fit: cover;">
|
||||
</div>
|
||||
<img src="{{ item.image.url }}"
|
||||
class="thumbnail-item active"
|
||||
data-index="0"
|
||||
data-full="{{ item.image.url }}"
|
||||
data-caption="{{ item.title }}"
|
||||
alt="{{ item.title }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Project Gallery -->
|
||||
{% if item.gallery_images.all %}
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-4">Галерея проекта</h3>
|
||||
<div class="row g-3">
|
||||
{% for gallery_image in item.gallery_images.all %}
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<a href="{{ gallery_image.image.url }}" data-lightbox="portfolio-gallery" {% if gallery_image.caption %}data-title="{{ gallery_image.caption }}"{% endif %}>
|
||||
<img src="{{ gallery_image.image.url }}" class="img-fluid rounded shadow-sm hover-zoom" alt="{{ gallery_image.caption }}" style="width: 100%; height: 250px; object-fit: cover; cursor: pointer; transition: transform 0.3s;">
|
||||
</a>
|
||||
{% if gallery_image.caption %}
|
||||
<p class="text-muted small mt-2 text-center">{{ gallery_image.caption }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<img src="{{ gallery_image.image.url }}"
|
||||
class="thumbnail-item"
|
||||
data-index="{{ forloop.counter }}"
|
||||
data-full="{{ gallery_image.image.url }}"
|
||||
data-caption="{{ gallery_image.caption|default:item.title }}"
|
||||
alt="{{ gallery_image.caption }}">
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Main Gallery Display -->
|
||||
<div class="gallery-main" id="galleryMain">
|
||||
<!-- Navigation Arrows -->
|
||||
<button class="gallery-nav prev" id="galleryPrev" aria-label="Предыдущее фото">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
<button class="gallery-nav next" id="galleryNext" aria-label="Следующее фото">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img src="{% if item.image %}{{ item.image.url }}{% else %}{{ item.gallery_images.first.image.url }}{% endif %}"
|
||||
class="gallery-main-image"
|
||||
id="galleryMainImage"
|
||||
data-lightbox="portfolio-gallery"
|
||||
alt="{{ item.title }}">
|
||||
|
||||
<!-- Gallery Counter -->
|
||||
<div class="gallery-counter" id="galleryCounter">
|
||||
<span id="currentImageIndex">1</span> / <span id="totalImages">{{ item.gallery_images.count|add:1 }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Image Caption -->
|
||||
<div class="gallery-caption" id="galleryCaption">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -102,4 +308,119 @@
|
||||
'albumLabel': 'Изображение %1 из %2'
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Advanced Gallery Navigation Script -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const galleryMain = document.getElementById('galleryMain');
|
||||
const mainImage = document.getElementById('galleryMainImage');
|
||||
const thumbnails = document.querySelectorAll('.thumbnail-item');
|
||||
const prevBtn = document.getElementById('galleryPrev');
|
||||
const nextBtn = document.getElementById('galleryNext');
|
||||
const counterCurrent = document.getElementById('currentImageIndex');
|
||||
const captionElement = document.getElementById('galleryCaption');
|
||||
|
||||
let currentIndex = 0;
|
||||
const totalImages = thumbnails.length;
|
||||
|
||||
// Update gallery display
|
||||
function updateGallery(index) {
|
||||
if (index < 0) index = totalImages - 1;
|
||||
if (index >= totalImages) index = 0;
|
||||
|
||||
currentIndex = index;
|
||||
|
||||
const currentThumbnail = thumbnails[index];
|
||||
const fullUrl = currentThumbnail.dataset.full;
|
||||
const caption = currentThumbnail.dataset.caption;
|
||||
|
||||
// Update main image
|
||||
mainImage.src = fullUrl;
|
||||
mainImage.setAttribute('href', fullUrl);
|
||||
|
||||
// Update active thumbnail
|
||||
thumbnails.forEach(thumb => thumb.classList.remove('active'));
|
||||
currentThumbnail.classList.add('active');
|
||||
|
||||
// Update counter
|
||||
counterCurrent.textContent = index + 1;
|
||||
|
||||
// Update caption
|
||||
if (caption && caption.trim() !== '') {
|
||||
captionElement.textContent = caption;
|
||||
captionElement.style.display = 'block';
|
||||
} else {
|
||||
captionElement.style.display = 'none';
|
||||
}
|
||||
|
||||
// Scroll thumbnail into view
|
||||
currentThumbnail.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Thumbnail click handlers
|
||||
thumbnails.forEach((thumbnail, index) => {
|
||||
thumbnail.addEventListener('click', () => updateGallery(index));
|
||||
});
|
||||
|
||||
// Navigation button handlers
|
||||
prevBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
updateGallery(currentIndex - 1);
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
updateGallery(currentIndex + 1);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
updateGallery(currentIndex - 1);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
updateGallery(currentIndex + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Touch/Swipe support for mobile devices
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
const swipeThreshold = 50;
|
||||
|
||||
galleryMain.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
}, { passive: true });
|
||||
|
||||
galleryMain.addEventListener('touchend', (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
handleSwipe();
|
||||
}, { passive: true });
|
||||
|
||||
function handleSwipe() {
|
||||
const swipeDistance = touchStartX - touchEndX;
|
||||
|
||||
if (Math.abs(swipeDistance) > swipeThreshold) {
|
||||
if (swipeDistance > 0) {
|
||||
// Swipe left - next image
|
||||
updateGallery(currentIndex + 1);
|
||||
} else {
|
||||
// Swipe right - previous image
|
||||
updateGallery(currentIndex - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse wheel support (optional)
|
||||
galleryMain.addEventListener('wheel', (e) => {
|
||||
if (Math.abs(e.deltaY) > 10) {
|
||||
e.preventDefault();
|
||||
if (e.deltaY > 0) {
|
||||
updateGallery(currentIndex + 1);
|
||||
} else {
|
||||
updateGallery(currentIndex - 1);
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -47,8 +47,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
{% if item.category %}
|
||||
<span class="badge bg-primary mb-2">{{ item.category.name }}</span>
|
||||
{% if item.categories.all %}
|
||||
<div class="mb-2">
|
||||
{% for category in item.categories.all %}
|
||||
<span class="badge bg-primary me-1">{{ category.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<h5 class="card-title">{{ item.title }}</h5>
|
||||
{% if item.client_name %}
|
||||
|
||||
@@ -412,10 +412,10 @@ def news_detail(request, slug):
|
||||
def portfolio_list(request):
|
||||
"""Список всех активных элементов портфолио"""
|
||||
category_id = request.GET.get('category')
|
||||
items = PortfolioItem.objects.filter(is_active=True)
|
||||
items = PortfolioItem.objects.filter(is_active=True).prefetch_related('categories')
|
||||
|
||||
if category_id:
|
||||
items = items.filter(category_id=category_id)
|
||||
items = items.filter(categories__id=category_id)
|
||||
|
||||
categories = Category.objects.all()
|
||||
return render(request, 'web/portfolio_list.html', {
|
||||
@@ -426,7 +426,7 @@ def portfolio_list(request):
|
||||
|
||||
def portfolio_detail(request, slug):
|
||||
"""Детальная страница элемента портфолио"""
|
||||
item = get_object_or_404(PortfolioItem, slug=slug, is_active=True)
|
||||
item = get_object_or_404(PortfolioItem.objects.prefetch_related('categories', 'gallery_images'), slug=slug, is_active=True)
|
||||
return render(request, 'web/portfolio_detail.html', {'item': item})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user