diff --git a/src/main/java/com/example/dateplanner/controllers/rest/ApiAdminController.java b/src/main/java/com/example/dateplanner/controllers/rest/ApiAdminController.java index 45528d6..984780e 100644 --- a/src/main/java/com/example/dateplanner/controllers/rest/ApiAdminController.java +++ b/src/main/java/com/example/dateplanner/controllers/rest/ApiAdminController.java @@ -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> 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 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 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 deleteGalleryPost(@PathVariable UUID id, + @AuthenticationPrincipal AppUser user) { + return galleryPostRepository.findById(id) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND))) + .flatMap(post -> galleryPostRepository.deleteById(id)); + } } diff --git a/src/main/java/com/example/dateplanner/controllers/rest/ApiGalleryController.java b/src/main/java/com/example/dateplanner/controllers/rest/ApiGalleryController.java new file mode 100644 index 0000000..4d3fe1e --- /dev/null +++ b/src/main/java/com/example/dateplanner/controllers/rest/ApiGalleryController.java @@ -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> 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 getLatestPost() { + return galleryPostRepository.findFirstByIsPublishedTrueOrderByPublishedAtDesc(); + } + + @GetMapping("/posts/{id}") + public Mono getPostById(@PathVariable UUID id) { + return galleryPostRepository.findById(id) + .filter(post -> Boolean.TRUE.equals(post.getIsPublished())); + } +} diff --git a/src/main/java/com/example/dateplanner/controllers/web/GalleryWebController.java b/src/main/java/com/example/dateplanner/controllers/web/GalleryWebController.java new file mode 100644 index 0000000..a025c0a --- /dev/null +++ b/src/main/java/com/example/dateplanner/controllers/web/GalleryWebController.java @@ -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 galleryPage() { + Map 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 galleryPostPage(@PathVariable UUID id) { + Map 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() + ); + } +} diff --git a/src/main/java/com/example/dateplanner/controllers/web/HomeController.java b/src/main/java/com/example/dateplanner/controllers/web/HomeController.java index 241f6d5..5cfed3b 100644 --- a/src/main/java/com/example/dateplanner/controllers/web/HomeController.java +++ b/src/main/java/com/example/dateplanner/controllers/web/HomeController.java @@ -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,17 +33,34 @@ public class HomeController extends BaseWebController { model.put("title", "Home"); model.put("index", "home"); - return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc() - .map(settings -> { + Mono 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 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); - return model; - })) + .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) .map(m -> Rendering.view("template").model(m).build()); } diff --git a/src/main/java/com/example/dateplanner/models/entities/GalleryPost.java b/src/main/java/com/example/dateplanner/models/entities/GalleryPost.java new file mode 100644 index 0000000..c0ca5df --- /dev/null +++ b/src/main/java/com/example/dateplanner/models/entities/GalleryPost.java @@ -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 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; +} diff --git a/src/main/java/com/example/dateplanner/repositories/GalleryPostRepository.java b/src/main/java/com/example/dateplanner/repositories/GalleryPostRepository.java new file mode 100644 index 0000000..4f3aabf --- /dev/null +++ b/src/main/java/com/example/dateplanner/repositories/GalleryPostRepository.java @@ -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 { + + Flux findByIsPublishedTrueOrderByPublishedAtDesc(); + + Mono findFirstByIsPublishedTrueOrderByPublishedAtDesc(); + + @Query("SELECT * FROM gallery_posts ORDER BY published_at DESC NULLS LAST, created_at DESC LIMIT :limit OFFSET :offset") + Flux findRecentPublished(int limit, int offset); + + @Query("SELECT COUNT(*) FROM gallery_posts WHERE is_published = true") + Mono countPublished(); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 53aa366..02bdce3 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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} diff --git a/src/main/resources/db/migration/V1_0_7__gallery_posts.sql b/src/main/resources/db/migration/V1_0_7__gallery_posts.sql new file mode 100644 index 0000000..2b1f688 --- /dev/null +++ b/src/main/resources/db/migration/V1_0_7__gallery_posts.sql @@ -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); diff --git a/src/main/resources/static/js/site/blocks/header.js b/src/main/resources/static/js/site/blocks/header.js index add928c..5151ec2 100644 --- a/src/main/resources/static/js/site/blocks/header.js +++ b/src/main/resources/static/js/site/blocks/header.js @@ -29,6 +29,11 @@ export function initHeader($header) { Мероприятия +
${authed diff --git a/src/main/resources/static/js/site/pages/admin/admin.js b/src/main/resources/static/js/site/pages/admin/admin.js index 161261e..2712a79 100644 --- a/src/main/resources/static/js/site/pages/admin/admin.js +++ b/src/main/resources/static/js/site/pages/admin/admin.js @@ -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')); diff --git a/src/main/resources/static/js/site/pages/admin/gallerySection.js b/src/main/resources/static/js/site/pages/admin/gallerySection.js new file mode 100644 index 0000000..6819ea1 --- /dev/null +++ b/src/main/resources/static/js/site/pages/admin/gallerySection.js @@ -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('
'); + + $.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(` +
+ +
Нет публикаций
+

Создайте первую публикацию в галерее

+
+ `); + return; + } + + posts.forEach(post => { + $globalContainer.append(renderCard(post)); + }); + }, + error: () => $globalContainer.html('
Ошибка загрузки галереи
') + }); +} + +function renderCard(post) { + const imagesHtml = post.imageUrls && post.imageUrls.length > 0 + ? `` + : '
Нет изображений
'; + + const pubBadge = post.isPublished + ? 'Опубликовано' + : 'Черновик'; + + return $(` + + `).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(``); + const uploader = createSingleUploader($(`#${uploaderId}`), url); + galleryImageUploaders.push(uploader); + }); + + // Add button + $zone.append(``); + + $('#gallery-add-img-btn').on('click', function () { + const idx = galleryImageUploaders.length; + const uploaderId = `gallery-uploader-${idx}`; + $(this).before(``); + 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(` +
+ preview + +
+ `); + $container.find('.img-uploader__remove').on('click', (e) => { + e.stopPropagation(); + currentUrl = null; + render(); + }); + } else { + $container.html(` +
+
+ +
+
+ `); + $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( + '
' + ); + try { + currentUrl = await uploadImage(file); + render(); + } catch (err) { + console.error(err); + $container.find('.img-uploader__zone').removeClass('uploading').html( + '
Ошибка
' + ); + } + }; + 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('Сохранение...').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('Ошибка при удалении') + }); +} diff --git a/src/main/resources/static/js/site/pages/gallery/gallery.js b/src/main/resources/static/js/site/pages/gallery/gallery.js new file mode 100644 index 0000000..80fc1e3 --- /dev/null +++ b/src/main/resources/static/js/site/pages/gallery/gallery.js @@ -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(` + + `); + 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(` + + `); + + 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 ` +
+
+
+

${esc(post.title)}

+ + ${post.content ? `
${esc(post.content)}
` : ''} +
+
+ `; +} + +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('
'); + + $.ajax({ + url: `${API}/posts`, + type: 'GET', + data: { page, size: 12 }, + success: (response) => { + $container.empty(); + if (!response.content || response.content.length === 0) { + $container.html(` +
+ +
Пока нет публикаций
+

Следите за обновлениями!

+
+ `); + return; + } + + const $list = $('
'); + 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 = $(` + + `); + + $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('
Ошибка загрузки галереи
') + }); +} + +$(document).ready(() => { + loadPosts(0); +}); diff --git a/src/main/resources/static/js/site/pages/home/home.js b/src/main/resources/static/js/site/pages/home/home.js index 4dec6cb..0f18528 100644 --- a/src/main/resources/static/js/site/pages/home/home.js +++ b/src/main/resources/static/js/site/pages/home/home.js @@ -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(` + + ${images.length > 1 ? ` + + + + ` : ''} + `); + + 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); +} diff --git a/src/main/resources/templates/pages/admin-panel.html b/src/main/resources/templates/pages/admin-panel.html index d37aa2f..929795a 100644 --- a/src/main/resources/templates/pages/admin-panel.html +++ b/src/main/resources/templates/pages/admin-panel.html @@ -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 @@
+ + + + + + +
+
diff --git a/src/main/resources/templates/pages/gallery.html b/src/main/resources/templates/pages/gallery.html new file mode 100644 index 0000000..3f565ae --- /dev/null +++ b/src/main/resources/templates/pages/gallery.html @@ -0,0 +1,169 @@ + + + + +
+ +
+ + diff --git a/src/main/resources/templates/pages/home.html b/src/main/resources/templates/pages/home.html index 8288d1d..828dead 100644 --- a/src/main/resources/templates/pages/home.html +++ b/src/main/resources/templates/pages/home.html @@ -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 @@ + + +