mirror of
https://github.com/LOBSTERVOVA/Tennis-Site.git
synced 2026-04-17 17:40:49 +03:00
добавил галерею
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
14
src/main/resources/db/migration/V1_0_7__gallery_posts.sql
Normal file
14
src/main/resources/db/migration/V1_0_7__gallery_posts.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
294
src/main/resources/static/js/site/pages/admin/gallerySection.js
Normal file
294
src/main/resources/static/js/site/pages/admin/gallerySection.js
Normal 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('Ошибка при удалении')
|
||||
});
|
||||
}
|
||||
169
src/main/resources/static/js/site/pages/gallery/gallery.js
Normal file
169
src/main/resources/static/js/site/pages/gallery/gallery.js
Normal 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}">«</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}">»</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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
169
src/main/resources/templates/pages/gallery.html
Normal file
169
src/main/resources/templates/pages/gallery.html
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user