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.AppUser;
|
||||||
import com.example.dateplanner.models.entities.Court;
|
import com.example.dateplanner.models.entities.Court;
|
||||||
import com.example.dateplanner.models.entities.EventApplication;
|
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.SiteSettings;
|
||||||
import com.example.dateplanner.models.entities.TennisClub;
|
import com.example.dateplanner.models.entities.TennisClub;
|
||||||
import com.example.dateplanner.models.entities.TennisEvent;
|
import com.example.dateplanner.models.entities.TennisEvent;
|
||||||
import com.example.dateplanner.repositories.CourtRepository;
|
import com.example.dateplanner.repositories.CourtRepository;
|
||||||
import com.example.dateplanner.repositories.EventApplicationRepository;
|
import com.example.dateplanner.repositories.EventApplicationRepository;
|
||||||
|
import com.example.dateplanner.repositories.GalleryPostRepository;
|
||||||
import com.example.dateplanner.repositories.SiteSettingsRepository;
|
import com.example.dateplanner.repositories.SiteSettingsRepository;
|
||||||
import com.example.dateplanner.repositories.TennisClubRepository;
|
import com.example.dateplanner.repositories.TennisClubRepository;
|
||||||
import com.example.dateplanner.repositories.TennisEventRepository;
|
import com.example.dateplanner.repositories.TennisEventRepository;
|
||||||
@@ -44,6 +46,7 @@ public class ApiAdminController {
|
|||||||
private final ApplicationService applicationService;
|
private final ApplicationService applicationService;
|
||||||
private final MinioService minioService;
|
private final MinioService minioService;
|
||||||
private final SiteSettingsRepository siteSettingsRepository;
|
private final SiteSettingsRepository siteSettingsRepository;
|
||||||
|
private final GalleryPostRepository galleryPostRepository;
|
||||||
|
|
||||||
// ── Загрузка изображений ───────────────────────────────────────────────────
|
// ── Загрузка изображений ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -498,4 +501,56 @@ public class ApiAdminController {
|
|||||||
return siteSettingsRepository.save(settings);
|
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;
|
package com.example.dateplanner.controllers.web;
|
||||||
|
|
||||||
|
import com.example.dateplanner.models.entities.GalleryPost;
|
||||||
import com.example.dateplanner.models.entities.SiteSettings;
|
import com.example.dateplanner.models.entities.SiteSettings;
|
||||||
|
import com.example.dateplanner.repositories.GalleryPostRepository;
|
||||||
import com.example.dateplanner.repositories.SiteSettingsRepository;
|
import com.example.dateplanner.repositories.SiteSettingsRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -20,6 +22,7 @@ import java.util.UUID;
|
|||||||
public class HomeController extends BaseWebController {
|
public class HomeController extends BaseWebController {
|
||||||
|
|
||||||
private final SiteSettingsRepository siteSettingsRepository;
|
private final SiteSettingsRepository siteSettingsRepository;
|
||||||
|
private final GalleryPostRepository galleryPostRepository;
|
||||||
|
|
||||||
private static final String DEFAULT_HERO_IMAGE =
|
private static final String DEFAULT_HERO_IMAGE =
|
||||||
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height";
|
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height";
|
||||||
@@ -30,17 +33,34 @@ public class HomeController extends BaseWebController {
|
|||||||
model.put("title", "Home");
|
model.put("title", "Home");
|
||||||
model.put("index", "home");
|
model.put("index", "home");
|
||||||
|
|
||||||
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
|
Mono<SiteSettings> settingsMono = siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
|
||||||
.map(settings -> {
|
.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("heroImageUrl", settings.getHeroImageUrl() != null ? settings.getHeroImageUrl() : DEFAULT_HERO_IMAGE);
|
||||||
model.put("backgroundImageUrl", settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_HERO_IMAGE);
|
model.put("backgroundImageUrl", settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_HERO_IMAGE);
|
||||||
|
if (latestPost != null) {
|
||||||
|
model.put("latestGalleryPost", latestPost);
|
||||||
|
}
|
||||||
return model;
|
return model;
|
||||||
})
|
})
|
||||||
.switchIfEmpty(Mono.fromCallable(() -> {
|
.switchIfEmpty(Mono.zip(settingsMono, Mono.just((GalleryPost) null))
|
||||||
model.put("heroImageUrl", DEFAULT_HERO_IMAGE);
|
.map(tuple -> {
|
||||||
model.put("backgroundImageUrl", DEFAULT_HERO_IMAGE);
|
SiteSettings settings = tuple.getT1();
|
||||||
return model;
|
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)
|
.flatMap(this::addAuthToModel)
|
||||||
.map(m -> Rendering.view("template").model(m).build());
|
.map(m -> Rendering.view("template").model(m).build());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.username=
|
||||||
minio.password=
|
minio.password=
|
||||||
#========================== email config =========================
|
#========================== email config =========================
|
||||||
app.system.email=tennisworld.kids@gmail.com
|
app.system.email=mohaned.alhalili@yandex.ru
|
||||||
app.system.email.password=oypectvsvyszlzqb
|
app.system.email.password=lxdeaoebwmtkhths
|
||||||
app.system.email.port=465
|
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.host=${app.system.email.server}
|
||||||
spring.mail.port=${app.system.email.port}
|
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>Мероприятия
|
<i class="bi bi-calendar-event me-1"></i>Мероприятия
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/gallery">
|
||||||
|
<i class="bi bi-images me-1"></i>Галерея
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</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">
|
<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
|
${authed
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { initAdminClubs } from './clubsSection.js';
|
|||||||
import { initAdminEvents } from './eventsSection.js';
|
import { initAdminEvents } from './eventsSection.js';
|
||||||
import { initApplicationsSection } from './applicationsSection.js';
|
import { initApplicationsSection } from './applicationsSection.js';
|
||||||
import { initSiteImages } from './siteImagesSection.js';
|
import { initSiteImages } from './siteImagesSection.js';
|
||||||
|
import { initAdminGallery } from './gallerySection.js';
|
||||||
|
|
||||||
function onStats(stats) {
|
function onStats(stats) {
|
||||||
if (stats.clubs != null) $('#stat-clubs').text(stats.clubs);
|
if (stats.clubs != null) $('#stat-clubs').text(stats.clubs);
|
||||||
@@ -14,3 +15,4 @@ initAdminClubs($('#admin-clubs-container'), onStats);
|
|||||||
initAdminEvents($('#admin-events-container'), onStats);
|
initAdminEvents($('#admin-events-container'), onStats);
|
||||||
initApplicationsSection($('#admin-applications-container'));
|
initApplicationsSection($('#admin-applications-container'));
|
||||||
initSiteImages();
|
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();
|
initApplyModal();
|
||||||
initClubsSection($('#home-clubs-container'));
|
initClubsSection($('#home-clubs-container'));
|
||||||
initEventsSection($('#home-events-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;
|
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) {
|
@media (max-width: 767px) {
|
||||||
.admin-header {
|
.admin-header {
|
||||||
padding: 35px 0;
|
padding: 35px 0;
|
||||||
@@ -728,6 +800,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/js/site/pages/admin/admin.js"></script>
|
<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; }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.hero-title { font-size: 2.2rem; }
|
.hero-title { font-size: 2.2rem; }
|
||||||
.hero-section { padding: 60px 0 50px; }
|
.hero-section { padding: 60px 0 50px; }
|
||||||
@@ -127,6 +190,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ── -->
|
<!-- ── CTA ── -->
|
||||||
<section class="py-5" style="background:linear-gradient(135deg,#2c3e50 0%,#34495e 100%);color:white">
|
<section class="py-5" style="background:linear-gradient(135deg,#2c3e50 0%,#34495e 100%);color:white">
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user