добавил галерею

This commit is contained in:
Lobstervova
2026-04-15 13:51:06 +03:00
parent 90f73bf7a4
commit 506f4421df
16 changed files with 1162 additions and 10 deletions

View File

@@ -8,11 +8,13 @@ import com.example.dateplanner.dto.PageResponse;
import com.example.dateplanner.models.entities.AppUser;
import com.example.dateplanner.models.entities.Court;
import com.example.dateplanner.models.entities.EventApplication;
import com.example.dateplanner.models.entities.GalleryPost;
import com.example.dateplanner.models.entities.SiteSettings;
import com.example.dateplanner.models.entities.TennisClub;
import com.example.dateplanner.models.entities.TennisEvent;
import com.example.dateplanner.repositories.CourtRepository;
import com.example.dateplanner.repositories.EventApplicationRepository;
import com.example.dateplanner.repositories.GalleryPostRepository;
import com.example.dateplanner.repositories.SiteSettingsRepository;
import com.example.dateplanner.repositories.TennisClubRepository;
import com.example.dateplanner.repositories.TennisEventRepository;
@@ -44,6 +46,7 @@ public class ApiAdminController {
private final ApplicationService applicationService;
private final MinioService minioService;
private final SiteSettingsRepository siteSettingsRepository;
private final GalleryPostRepository galleryPostRepository;
// ── Загрузка изображений ───────────────────────────────────────────────────
@@ -498,4 +501,56 @@ public class ApiAdminController {
return siteSettingsRepository.save(settings);
}));
}
// ── Галерея ───────────────────────────────────────────────────────────────
@GetMapping("/gallery")
public Mono<PageResponse<GalleryPost>> getGalleryPosts(
@AuthenticationPrincipal AppUser user,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return galleryPostRepository.countPublished()
.flatMap(total -> galleryPostRepository.findRecentPublished(size, page * size)
.collectList()
.map(posts -> new PageResponse<>(posts, page, size, total)));
}
@PostMapping("/gallery")
public Mono<GalleryPost> createGalleryPost(@RequestBody GalleryPost post,
@AuthenticationPrincipal AppUser user) {
post.setCreatedAt(LocalDateTime.now());
post.setUpdatedAt(LocalDateTime.now());
if (post.getPublishedAt() == null && Boolean.TRUE.equals(post.getIsPublished())) {
post.setPublishedAt(LocalDateTime.now());
}
return galleryPostRepository.save(post);
}
@PutMapping("/gallery/{id}")
public Mono<GalleryPost> updateGalleryPost(@PathVariable UUID id,
@RequestBody GalleryPost body,
@AuthenticationPrincipal AppUser user) {
return galleryPostRepository.findById(id)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)))
.flatMap(existing -> {
existing.setTitle(body.getTitle());
existing.setContent(body.getContent());
existing.setImageUrls(body.getImageUrls());
existing.setIsPublished(body.getIsPublished());
if (Boolean.TRUE.equals(body.getIsPublished()) && existing.getPublishedAt() == null) {
existing.setPublishedAt(LocalDateTime.now());
}
existing.setUpdatedAt(LocalDateTime.now());
return galleryPostRepository.save(existing);
});
}
@DeleteMapping("/gallery/{id}")
public Mono<Void> deleteGalleryPost(@PathVariable UUID id,
@AuthenticationPrincipal AppUser user) {
return galleryPostRepository.findById(id)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)))
.flatMap(post -> galleryPostRepository.deleteById(id));
}
}

View File

@@ -0,0 +1,41 @@
package com.example.dateplanner.controllers.rest;
import com.example.dateplanner.dto.PageResponse;
import com.example.dateplanner.models.entities.GalleryPost;
import com.example.dateplanner.repositories.GalleryPostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.UUID;
@RestController
@RequestMapping("/api/gallery")
@RequiredArgsConstructor
public class ApiGalleryController {
private final GalleryPostRepository galleryPostRepository;
@GetMapping("/posts")
public Mono<PageResponse<GalleryPost>> getPublishedPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size) {
return galleryPostRepository.countPublished()
.flatMap(total -> galleryPostRepository.findRecentPublished(size, page * size)
.collectList()
.map(posts -> new PageResponse<>(posts, page, size, total)));
}
@GetMapping("/posts/latest")
public Mono<GalleryPost> getLatestPost() {
return galleryPostRepository.findFirstByIsPublishedTrueOrderByPublishedAtDesc();
}
@GetMapping("/posts/{id}")
public Mono<GalleryPost> getPostById(@PathVariable UUID id) {
return galleryPostRepository.findById(id)
.filter(post -> Boolean.TRUE.equals(post.getIsPublished()));
}
}

View File

@@ -0,0 +1,44 @@
package com.example.dateplanner.controllers.web;
import com.example.dateplanner.models.entities.GalleryPost;
import com.example.dateplanner.repositories.GalleryPostRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.reactive.result.view.Rendering;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Controller
@RequiredArgsConstructor
public class GalleryWebController extends BaseWebController {
private final GalleryPostRepository galleryPostRepository;
@GetMapping("/gallery")
public Mono<Rendering> galleryPage() {
Map<String, Object> model = new HashMap<>();
model.put("title", "Галерея");
model.put("index", "gallery");
return addAuthToModel(model).map(m ->
Rendering.view("template").model(m).build()
);
}
@GetMapping("/gallery/{id}")
public Mono<Rendering> galleryPostPage(@PathVariable UUID id) {
Map<String, Object> model = new HashMap<>();
model.put("title", "Пост галереи");
model.put("index", "gallery-post");
model.put("postId", id.toString());
return addAuthToModel(model).map(m ->
Rendering.view("template").model(m).build()
);
}
}

View File

@@ -1,6 +1,8 @@
package com.example.dateplanner.controllers.web;
import com.example.dateplanner.models.entities.GalleryPost;
import com.example.dateplanner.models.entities.SiteSettings;
import com.example.dateplanner.repositories.GalleryPostRepository;
import com.example.dateplanner.repositories.SiteSettingsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,6 +22,7 @@ import java.util.UUID;
public class HomeController extends BaseWebController {
private final SiteSettingsRepository siteSettingsRepository;
private final GalleryPostRepository galleryPostRepository;
private static final String DEFAULT_HERO_IMAGE =
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height";
@@ -30,15 +33,32 @@ public class HomeController extends BaseWebController {
model.put("title", "Home");
model.put("index", "home");
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.map(settings -> {
Mono<SiteSettings> settingsMono = siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.switchIfEmpty(Mono.defer(() -> {
SiteSettings s = new SiteSettings();
s.setHeroImageUrl(DEFAULT_HERO_IMAGE);
s.setBackgroundImageUrl(DEFAULT_HERO_IMAGE);
return siteSettingsRepository.save(s);
}));
Mono<GalleryPost> latestPostMono = galleryPostRepository.findFirstByIsPublishedTrueOrderByPublishedAtDesc();
return Mono.zip(settingsMono, latestPostMono)
.map(tuple -> {
SiteSettings settings = tuple.getT1();
GalleryPost latestPost = tuple.getT2();
model.put("heroImageUrl", settings.getHeroImageUrl() != null ? settings.getHeroImageUrl() : DEFAULT_HERO_IMAGE);
model.put("backgroundImageUrl", settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_HERO_IMAGE);
if (latestPost != null) {
model.put("latestGalleryPost", latestPost);
}
return model;
})
.switchIfEmpty(Mono.fromCallable(() -> {
model.put("heroImageUrl", DEFAULT_HERO_IMAGE);
model.put("backgroundImageUrl", DEFAULT_HERO_IMAGE);
.switchIfEmpty(Mono.zip(settingsMono, Mono.just((GalleryPost) null))
.map(tuple -> {
SiteSettings settings = tuple.getT1();
model.put("heroImageUrl", settings.getHeroImageUrl() != null ? settings.getHeroImageUrl() : DEFAULT_HERO_IMAGE);
model.put("backgroundImageUrl", settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_HERO_IMAGE);
return model;
}))
.flatMap(this::addAuthToModel)

View File

@@ -0,0 +1,41 @@
package com.example.dateplanner.models.entities;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Data
@NoArgsConstructor
@Table(name = "gallery_posts")
public class GalleryPost {
@Id
@Column("uuid")
private UUID uuid;
@NonNull
private String title;
private String content;
@Column("image_urls")
private List<String> imageUrls;
@Column("is_published")
private Boolean isPublished = Boolean.TRUE;
@Column("published_at")
private LocalDateTime publishedAt;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.example.dateplanner.repositories;
import com.example.dateplanner.models.entities.GalleryPost;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.UUID;
public interface GalleryPostRepository extends R2dbcRepository<GalleryPost, UUID> {
Flux<GalleryPost> findByIsPublishedTrueOrderByPublishedAtDesc();
Mono<GalleryPost> findFirstByIsPublishedTrueOrderByPublishedAtDesc();
@Query("SELECT * FROM gallery_posts ORDER BY published_at DESC NULLS LAST, created_at DESC LIMIT :limit OFFSET :offset")
Flux<GalleryPost> findRecentPublished(int limit, int offset);
@Query("SELECT COUNT(*) FROM gallery_posts WHERE is_published = true")
Mono<Long> countPublished();
}

View File

@@ -29,10 +29,10 @@ minio.cdn=
minio.username=
minio.password=
#========================== email config =========================
app.system.email=tennisworld.kids@gmail.com
app.system.email.password=oypectvsvyszlzqb
app.system.email=mohaned.alhalili@yandex.ru
app.system.email.password=lxdeaoebwmtkhths
app.system.email.port=465
app.system.email.server=smtp.gmail.com
app.system.email.server=smtp.yandex.ru
spring.mail.host=${app.system.email.server}
spring.mail.port=${app.system.email.port}

View File

@@ -0,0 +1,14 @@
-- Gallery posts for photo news section
CREATE TABLE IF NOT EXISTS gallery_posts (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT,
image_urls TEXT[],
is_published BOOLEAN NOT NULL DEFAULT TRUE,
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_gallery_published ON gallery_posts(published_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_gallery_published_flag ON gallery_posts(is_published);

View File

@@ -29,6 +29,11 @@ export function initHeader($header) {
<i class="bi bi-calendar-event me-1"></i>Мероприятия
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/gallery">
<i class="bi bi-images me-1"></i>Галерея
</a>
</li>
</ul>
<div class="ms-0 ms-lg-3 mt-2 mt-lg-0 d-flex gap-2 align-items-center pb-2 pb-lg-0">
${authed

View File

@@ -2,6 +2,7 @@ import { initAdminClubs } from './clubsSection.js';
import { initAdminEvents } from './eventsSection.js';
import { initApplicationsSection } from './applicationsSection.js';
import { initSiteImages } from './siteImagesSection.js';
import { initAdminGallery } from './gallerySection.js';
function onStats(stats) {
if (stats.clubs != null) $('#stat-clubs').text(stats.clubs);
@@ -14,3 +15,4 @@ initAdminClubs($('#admin-clubs-container'), onStats);
initAdminEvents($('#admin-events-container'), onStats);
initApplicationsSection($('#admin-applications-container'));
initSiteImages();
initAdminGallery($('#admin-gallery-container'));

View File

@@ -0,0 +1,294 @@
import { getCsrfToken } from '../../utils/helpers.js';
const API = '/api/admin';
let editingPostId = null;
let galleryImageUploaders = [];
let $globalContainer;
function formatDate(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function esc(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export function initAdminGallery($container) {
$globalContainer = $container;
loadGalleryPosts();
setupModal();
}
function loadGalleryPosts() {
$globalContainer.html('<div class="text-center py-4"><div class="spinner-border text-success"></div></div>');
$.ajax({
url: `${API}/gallery`,
type: 'GET',
data: { page: 0, size: 50 },
success: (response) => {
$globalContainer.empty();
const posts = response.content || [];
if (posts.length === 0) {
$globalContainer.html(`
<div class="text-center py-5 text-muted">
<i class="bi bi-images fs-1 d-block mb-3 opacity-50"></i>
<h5>Нет публикаций</h5>
<p>Создайте первую публикацию в галерее</p>
</div>
`);
return;
}
posts.forEach(post => {
$globalContainer.append(renderCard(post));
});
},
error: () => $globalContainer.html('<div class="alert alert-danger">Ошибка загрузки галереи</div>')
});
}
function renderCard(post) {
const imagesHtml = post.imageUrls && post.imageUrls.length > 0
? `<div class="admin-gallery-card-images">
${post.imageUrls.map(img => `<img src="${(window.cdn || '')}${img}" alt="img">`).join('')}
</div>`
: '<div class="text-muted small mb-2">Нет изображений</div>';
const pubBadge = post.isPublished
? '<span class="badge bg-success">Опубликовано</span>'
: '<span class="badge bg-warning text-dark">Черновик</span>';
return $(`
<div class="admin-gallery-card" data-post-id="${post.uuid}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<div class="admin-gallery-card-title">${esc(post.title)}</div>
<div class="admin-gallery-card-date">
<i class="bi bi-calendar3 me-1"></i>${formatDate(post.publishedAt || post.createdAt)}
${pubBadge}
</div>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-primary edit-gallery-btn">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger delete-gallery-btn">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
${post.content ? `<div class="text-muted small mb-2">${esc(post.content).substring(0, 150)}${post.content.length > 150 ? '...' : ''}</div>` : ''}
${imagesHtml}
</div>
`).on('click', '.edit-gallery-btn', () => openEditModal(post))
.on('click', '.delete-gallery-btn', () => deletePost(post.uuid));
}
function setupModal() {
const modal = document.getElementById('gallery-post-modal');
if (!modal) return;
$('#add-gallery-post-btn').on('click', () => openCreateModal());
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
modal.addEventListener('shown.bs.modal', () => {
initGalleryImageUploaders();
});
modal.addEventListener('hidden.bs.modal', () => {
clearForm();
});
$('#save-gallery-post-btn').on('click', savePost);
}
function initGalleryImageUploaders() {
const $zone = $('#gallery-images-zone');
$zone.empty();
galleryImageUploaders = [];
// Render existing images or add new upload button
const existingUrls = $zone.data('existing') || [];
existingUrls.forEach((url, i) => {
const uploaderId = `gallery-uploader-${i}`;
$zone.append(`<div id="${uploaderId}" class="gallery-img-uploader"></div>`);
const uploader = createSingleUploader($(`#${uploaderId}`), url);
galleryImageUploaders.push(uploader);
});
// Add button
$zone.append(`<div class="gallery-img-add" id="gallery-add-img-btn" title="Добавить изображение"><i class="bi bi-plus-lg"></i></div>`);
$('#gallery-add-img-btn').on('click', function () {
const idx = galleryImageUploaders.length;
const uploaderId = `gallery-uploader-${idx}`;
$(this).before(`<div id="${uploaderId}" class="gallery-img-uploader"></div>`);
const uploader = createSingleUploader($(`#${uploaderId}`), null);
galleryImageUploaders.push(uploader);
});
}
function createSingleUploader($container, initialUrl) {
let currentUrl = initialUrl;
function render() {
if (currentUrl) {
const cdn = window.cdn || '';
$container.html(`
<div class="img-uploader__zone">
<img src="${cdn}${currentUrl}" class="img-uploader__preview" alt="preview">
<button class="img-uploader__remove" type="button"><i class="bi bi-x-lg"></i></button>
</div>
`);
$container.find('.img-uploader__remove').on('click', (e) => {
e.stopPropagation();
currentUrl = null;
render();
});
} else {
$container.html(`
<div class="img-uploader__zone">
<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
</div>
</div>
`);
$container.find('.img-uploader__zone').on('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
$container.find('.img-uploader__zone').addClass('uploading').html(
'<div class="img-uploader__loading"><div class="spinner-border text-light"></div></div>'
);
try {
currentUrl = await uploadImage(file);
render();
} catch (err) {
console.error(err);
$container.find('.img-uploader__zone').removeClass('uploading').html(
'<div class="img-uploader__placeholder"><i class="bi bi-exclamation-triangle text-danger"></i><span class="text-danger">Ошибка</span></div>'
);
}
};
input.click();
});
}
}
render();
return {
getUrl: () => currentUrl,
setUrl: (url) => { currentUrl = url; render(); },
clear: () => { currentUrl = null; render(); }
};
}
async function uploadImage(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API}/upload/image`, {
method: 'POST',
headers: { 'X-XSRF-TOKEN': getCsrfToken() },
body: formData,
});
if (!res.ok) throw new Error('Ошибка загрузки');
const data = await res.json();
return data.url;
}
function openCreateModal() {
editingPostId = null;
$('#gallery-modal-title').text('Новая публикация');
bootstrap.Modal.getOrCreateInstance(document.getElementById('gallery-post-modal')).show();
}
function openEditModal(post) {
editingPostId = post.uuid;
$('#gallery-modal-title').text('Редактировать публикацию');
$('#gallery-post-title').val(post.title || '');
$('#gallery-post-content').val(post.content || '');
$('#gallery-post-published').prop('checked', post.isPublished !== false);
const $zone = $('#gallery-images-zone');
$zone.data('existing', post.imageUrls || []);
bootstrap.Modal.getOrCreateInstance(document.getElementById('gallery-post-modal')).show();
}
function clearForm() {
editingPostId = null;
$('#gallery-modal-title').text('Новая публикация');
$('#gallery-post-title').val('');
$('#gallery-post-content').val('');
$('#gallery-post-published').prop('checked', true);
const $zone = $('#gallery-images-zone');
$zone.data('existing', []);
$zone.empty();
galleryImageUploaders = [];
}
function savePost() {
const title = $('#gallery-post-title').val().trim();
if (!title) { alert('Заголовок обязателен'); return; }
const imageUrls = galleryImageUploaders
.map(u => u.getUrl())
.filter(u => u !== null);
const payload = {
title,
content: $('#gallery-post-content').val().trim() || null,
imageUrls: imageUrls.length > 0 ? imageUrls : null,
isPublished: $('#gallery-post-published').is(':checked')
};
const $btn = $('#save-gallery-post-btn');
const orig = $btn.html();
$btn.html('<span class="spinner-border spinner-border-sm me-1"></span>Сохранение...').prop('disabled', true);
const isEdit = editingPostId !== null;
$.ajax({
url: isEdit ? `${API}/gallery/${editingPostId}` : `${API}/gallery`,
type: isEdit ? 'PUT' : 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
beforeSend: xhr => xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()),
success: () => {
bootstrap.Modal.getInstance(document.getElementById('gallery-post-modal')).hide();
loadGalleryPosts();
},
error: () => alert(isEdit ? 'Ошибка при обновлении' : 'Ошибка при создании'),
complete: () => $btn.html(orig).prop('disabled', false)
});
}
function deletePost(id) {
if (!confirm('Удалить эту публикацию?')) return;
$.ajax({
url: `${API}/gallery/${id}`,
type: 'DELETE',
beforeSend: xhr => xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()),
success: () => loadGalleryPosts(),
error: () => alert('Ошибка при удалении')
});
}

View File

@@ -0,0 +1,169 @@
const API = '/api/gallery';
function formatDate(dt) {
if (!dt) return '';
return new Date(dt).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function initSlider($container, images) {
if (!images || images.length === 0) {
$container.html(`
<div class="gallery-no-images">
<i class="bi bi-image d-block"></i>
<p>Нет изображений</p>
</div>
`);
return;
}
const cdn = window.cdn || '';
let current = 0;
const trackId = 'slider-track-' + Math.random().toString(36).substr(2, 9);
const dotsId = 'slider-dots-' + Math.random().toString(36).substr(2, 9);
$container.html(`
<div class="gallery-slider">
<div class="gallery-slider-track" id="${trackId}">
${images.map(img => `<img src="${cdn}${img}" alt="gallery" loading="lazy">`).join('')}
</div>
${images.length > 1 ? `
<button class="gallery-slider-btn prev" data-dir="prev"><i class="bi bi-chevron-left"></i></button>
<button class="gallery-slider-btn next" data-dir="next"><i class="bi bi-chevron-right"></i></button>
<div class="gallery-slider-dots" id="${dotsId}">
${images.map((_, i) => `<span data-index="${i}" class="${i === 0 ? 'active' : ''}"></span>`).join('')}
</div>
` : ''}
</div>
`);
if (images.length <= 1) return;
const $track = $(`#${trackId}`);
const $dots = $(`#${dotsId}`);
function goTo(index) {
current = ((index % images.length) + images.length) % images.length;
$track.css('transform', `translateX(-${current * 100}%)`);
$dots.find('span').removeClass('active').eq(current).addClass('active');
}
$container.on('click', '.gallery-slider-btn', function () {
const dir = $(this).data('dir');
goTo(dir === 'prev' ? current - 1 : current + 1);
});
$container.on('click', '.gallery-slider-dots span', function () {
goTo(parseInt($(this).data('index')));
});
// Touch support
let startX = 0;
$container.on('touchstart', e => { startX = e.originalEvent.touches[0].clientX; });
$container.on('touchend', e => {
const diff = startX - e.originalEvent.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
goTo(diff > 0 ? current + 1 : current - 1);
}
});
}
function renderPost(post) {
const sliderId = 'slider-' + post.uuid;
return `
<div class="gallery-post-card">
<div id="${sliderId}"></div>
<div class="gallery-post-content">
<h3 class="gallery-post-title">${esc(post.title)}</h3>
<div class="gallery-post-date">
<i class="bi bi-calendar3 me-1"></i>${formatDate(post.publishedAt || post.createdAt)}
</div>
${post.content ? `<div class="gallery-post-text">${esc(post.content)}</div>` : ''}
</div>
</div>
`;
}
function esc(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function loadPosts(page = 0) {
const $container = $('#gallery-posts-container');
$container.html('<div class="text-center py-5"><div class="spinner-border text-success" role="status"></div></div>');
$.ajax({
url: `${API}/posts`,
type: 'GET',
data: { page, size: 12 },
success: (response) => {
$container.empty();
if (!response.content || response.content.length === 0) {
$container.html(`
<div class="text-center py-5 text-muted">
<i class="bi bi-images fs-1 d-block mb-3 opacity-50"></i>
<h5>Пока нет публикаций</h5>
<p>Следите за обновлениями!</p>
</div>
`);
return;
}
const $list = $('<div></div>');
response.content.forEach(post => {
$list.append(renderPost(post));
});
$container.append($list);
// Init sliders
response.content.forEach(post => {
if (post.imageUrls && post.imageUrls.length > 0) {
initSlider($(`#slider-${post.uuid}`), post.imageUrls);
}
});
// Pagination
if (response.totalPages > 1) {
const $pagination = $(`
<nav aria-label="Галерея">
<ul class="pagination justify-content-center mt-4">
<li class="page-item ${page === 0 ? 'disabled' : ''}">
<button class="page-link" data-page="${page - 1}">&laquo;</button>
</li>
${Array.from({ length: response.totalPages }, (_, i) => `
<li class="page-item ${i === page ? 'active' : ''}">
<button class="page-link" data-page="${i}">${i + 1}</button>
</li>
`).join('')}
<li class="page-item ${page >= response.totalPages - 1 ? 'disabled' : ''}">
<button class="page-link" data-page="${page + 1}">&raquo;</button>
</li>
</ul>
</nav>
`);
$pagination.on('click', '[data-page]', function (e) {
e.preventDefault();
const p = parseInt($(this).data('page'));
if (!isNaN(p) && p >= 0 && p < response.totalPages) {
loadPosts(p);
}
});
$container.append($pagination);
}
},
error: () => $container.html('<div class="alert alert-danger">Ошибка загрузки галереи</div>')
});
}
$(document).ready(() => {
loadPosts(0);
});

View File

@@ -5,3 +5,52 @@ import { initApplyModal } from './applyModal.js';
initApplyModal();
initClubsSection($('#home-clubs-container'));
initEventsSection($('#home-events-container'));
// ── Home Gallery Slider ──
function initHomeGallerySlider(images) {
const $container = $('#home-gallery-slider');
if (!$container.length || !images || images.length === 0) return;
const cdn = window.cdn || '';
let current = 0;
$container.html(`
<div class="gallery-slider-track">
${images.map(img => `<img src="${cdn}${img}" alt="gallery" loading="lazy">`).join('')}
</div>
${images.length > 1 ? `
<button class="gallery-slider-btn prev" data-dir="prev"><i class="bi bi-chevron-left"></i></button>
<button class="gallery-slider-btn next" data-dir="next"><i class="bi bi-chevron-right"></i></button>
<div class="gallery-slider-dots">
${images.map((_, i) => `<span data-index="${i}" class="${i === 0 ? 'active' : ''}"></span>`).join('')}
</div>
` : ''}
`);
if (images.length <= 1) return;
const $track = $container.find('.gallery-slider-track');
const $dots = $container.find('.gallery-slider-dots');
function goTo(index) {
current = ((index % images.length) + images.length) % images.length;
$track.css('transform', `translateX(-${current * 100}%)`);
$dots.find('span').removeClass('active').eq(current).addClass('active');
}
$container.on('click', '.gallery-slider-btn', function () {
goTo($(this).data('dir') === 'prev' ? current - 1 : current + 1);
});
$container.on('click', '.gallery-slider-dots span', function () {
goTo(parseInt($(this).data('index')));
});
}
// Init slider из данных, переданных через Thymeleaf
const $galleryPost = $('#home-gallery-container .gallery-post-preview');
if ($galleryPost.length) {
const imagesAttr = $galleryPost.data('images');
const images = imagesAttr ? imagesAttr.split(',') : [];
initHomeGallerySlider(images);
}

View File

@@ -594,6 +594,78 @@
padding: 3px 8px;
}
/* ── Gallery admin images grid ── */
.gallery-images-uploader {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.gallery-img-uploader {
width: 140px;
height: 100px;
}
.gallery-img-uploader .img-uploader__zone {
width: 100%;
height: 100%;
aspect-ratio: unset;
}
.gallery-img-add {
width: 140px;
height: 100px;
border: 2px dashed #dee2e6;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #adb5bd;
font-size: 2rem;
transition: all 0.2s;
}
.gallery-img-add:hover {
border-color: var(--tennis-green);
color: var(--tennis-green);
background: #f0fdf4;
}
/* ── Gallery admin cards ── */
.admin-gallery-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
transition: all 0.3s;
}
.admin-gallery-card:hover {
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
border-color: var(--tennis-green);
}
.admin-gallery-card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--tennis-dark);
margin-bottom: 8px;
}
.admin-gallery-card-date {
font-size: 0.8rem;
color: #95a5a6;
margin-bottom: 10px;
}
.admin-gallery-card-images {
display: flex;
gap: 8px;
margin-bottom: 12px;
overflow-x: auto;
}
.admin-gallery-card-images img {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
@media (max-width: 767px) {
.admin-header {
padding: 35px 0;
@@ -728,6 +800,56 @@
</div>
</div>
<!-- ── Gallery section ── -->
<div class="section-card" id="admin-gallery-section">
<div class="section-title d-flex flex-column flex-sm-row justify-content-start justify-content-sm-between align-items-start align-items-sm-center gap-2 mb-3">
<span><i class="bi bi-images me-2"></i>Галерея</span>
<button class="btn-admin" id="add-gallery-post-btn">
<i class="bi bi-plus-circle me-1"></i>Добавить публикацию
</button>
</div>
<div id="admin-gallery-container"></div>
</div>
</div>
<!-- Gallery Post Modal -->
<div class="modal fade" id="gallery-post-modal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 rounded-4 overflow-hidden shadow-xl">
<div class="modal-header-dark">
<div class="d-flex align-items-center gap-2">
<div class="modal-header-icon"><i class="bi bi-image"></i></div>
<h5 class="modal-title mb-0" id="gallery-modal-title">Новая публикация</h5>
</div>
<button type="button" class="btn-close btn-close-white opacity-75" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4" style="max-height:72vh;overflow-y:auto">
<div class="mb-3">
<label class="form-label fw-semibold">Заголовок <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="gallery-post-title" placeholder=" ">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Текст</label>
<textarea class="form-control" id="gallery-post-content" rows="4" placeholder=" "></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Изображения</label>
<div id="gallery-images-zone" class="gallery-images-uploader"></div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="gallery-post-published" checked>
<label class="form-check-label" for="gallery-post-published">Опубликовать</label>
</div>
</div>
<div class="modal-footer border-0 px-4 pb-4 pt-2 gap-2">
<button type="button" class="btn btn-light fw-semibold px-4 rounded-3" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn-admin rounded-3 px-4" id="save-gallery-post-btn">
<i class="bi bi-check-lg me-1"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/site/pages/admin/admin.js"></script>

View File

@@ -0,0 +1,169 @@
<style>
.gallery-hero {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
color: white;
padding: 80px 0 60px;
margin-bottom: 50px;
}
.gallery-hero h1 {
font-size: 3rem;
font-weight: 800;
margin-bottom: 15px;
}
.gallery-hero p {
font-size: 1.2rem;
opacity: 0.9;
}
.gallery-post-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
overflow: hidden;
margin-bottom: 40px;
transition: transform 0.3s, box-shadow 0.3s;
}
.gallery-post-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
.gallery-slider {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
background: #f0f2f4;
}
.gallery-slider-track {
display: flex;
height: 100%;
transition: transform 0.4s ease;
}
.gallery-slider-track img {
width: 100%;
height: 100%;
object-fit: cover;
flex-shrink: 0;
}
.gallery-slider-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.5);
color: white;
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
z-index: 2;
}
.gallery-slider-btn:hover {
background: rgba(0,0,0,0.7);
}
.gallery-slider-btn.prev { left: 12px; }
.gallery-slider-btn.next { right: 12px; }
.gallery-slider-dots {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 2;
}
.gallery-slider-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.5);
cursor: pointer;
transition: background 0.2s;
}
.gallery-slider-dots span.active {
background: white;
}
.gallery-post-content {
padding: 30px;
}
.gallery-post-title {
font-size: 1.5rem;
font-weight: 700;
color: #2c3e50;
margin-bottom: 12px;
}
.gallery-post-date {
font-size: 0.85rem;
color: #95a5a6;
margin-bottom: 16px;
}
.gallery-post-text {
color: #555;
line-height: 1.7;
white-space: pre-wrap;
}
.gallery-no-images {
text-align: center;
padding: 40px;
color: #999;
}
.gallery-no-images i {
font-size: 3rem;
margin-bottom: 15px;
color: #ddd;
}
@media (max-width: 768px) {
.gallery-hero {
padding: 60px 0 40px;
}
.gallery-hero h1 {
font-size: 2rem;
}
.gallery-slider {
height: 250px;
}
.gallery-post-content {
padding: 20px;
}
.gallery-post-title {
font-size: 1.2rem;
}
}
</style>
<div class="gallery-hero">
<div class="container text-center">
<h1><i class="bi bi-images me-2"></i>Галерея</h1>
<p>Фотоотчёты с мероприятий, турниров и тренировок</p>
</div>
</div>
<div class="container">
<div id="gallery-posts-container"></div>
</div>
<script type="module" src="/js/site/pages/gallery/gallery.js"></script>

View File

@@ -65,6 +65,69 @@
}
.btn-tennis-green:hover { background: #27ae60; color: #fff; }
.gallery-slider-home {
position: relative;
width: 100%;
height: 300px;
overflow: hidden;
background: #f0f2f4;
}
.gallery-slider-home .gallery-slider-track {
display: flex;
height: 100%;
transition: transform 0.4s ease;
}
.gallery-slider-home .gallery-slider-track img {
width: 100%;
height: 100%;
object-fit: cover;
flex-shrink: 0;
}
.gallery-slider-home .gallery-slider-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.5);
color: white;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
}
.gallery-slider-home .gallery-slider-btn:hover { background: rgba(0,0,0,0.7); }
.gallery-slider-home .gallery-slider-btn.prev { left: 8px; }
.gallery-slider-home .gallery-slider-btn.next { right: 8px; }
.gallery-slider-home .gallery-slider-dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 2;
}
.gallery-slider-home .gallery-slider-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.5);
cursor: pointer;
}
.gallery-slider-home .gallery-slider-dots span.active { background: white; }
@media (max-width: 768px) {
.hero-title { font-size: 2.2rem; }
.hero-section { padding: 60px 0 50px; }
@@ -127,6 +190,48 @@
</div>
</section>
<!-- ── Gallery ── -->
<section id="gallery-section" class="py-5 bg-light">
<div class="container">
<div class="row mb-4">
<div class="col-lg-8 mx-auto text-center">
<h2 class="fw-bold mb-2">Галерея</h2>
<p class="text-muted">Фотоотчёты с мероприятий и турниров</p>
</div>
</div>
<div id="home-gallery-container">
<div th:if="${latestGalleryPost != null}"
class="gallery-post-preview"
th:data-images="${latestGalleryPost.imageUrls != null ? #strings.arrayJoin(latestGalleryPost.imageUrls, ',') : ''}">
<div class="row g-0 align-items-center">
<div class="col-md-6">
<div id="home-gallery-slider" class="gallery-slider-home"></div>
</div>
<div class="col-md-6">
<div class="p-4">
<h3 class="fw-bold mb-2" th:text="${latestGalleryPost.title}"></h3>
<div class="text-muted small mb-3">
<i class="bi bi-calendar3 me-1"></i>
<span th:text="${#temporals.format(latestGalleryPost.publishedAt != null ? latestGalleryPost.publishedAt : latestGalleryPost.createdAt, 'dd MMMM yyyy')}"></span>
</div>
<div class="text-muted mb-3" th:if="${latestGalleryPost.content != null}"
th:text="${#strings.length(latestGalleryPost.content) > 200 ? #strings.substring(latestGalleryPost.content, 0, 200) + '...' : latestGalleryPost.content}"></div>
<a href="/gallery" class="btn btn-tennis">
<i class="bi bi-images me-2"></i>Показать больше
</a>
</div>
</div>
</div>
</div>
<div th:unless="${latestGalleryPost != null}" class="text-center py-4 text-muted">
<i class="bi bi-images fs-1 d-block mb-3 opacity-50"></i>
<p>Следите за обновлениями в галерее!</p>
<a href="/gallery" class="btn btn-sm btn-outline-secondary">Перейти в галерею</a>
</div>
</div>
</div>
</section>
<!-- ── CTA ── -->
<section class="py-5" style="background:linear-gradient(135deg,#2c3e50 0%,#34495e 100%);color:white">
<div class="container text-center">