diff --git a/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java b/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java index 4a4dab3..d044830 100644 --- a/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java +++ b/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java @@ -51,7 +51,7 @@ public class SecurityConfig { .addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) //.addFilterAfter(errorHandlingFilter, SecurityWebFiltersOrder.AUTHENTICATION) .authorizeExchange(exchange -> exchange - .pathMatchers("/account/login","/error","/error/**", "/account/logout").permitAll() + .pathMatchers("/account/login","/error","/error/**", "/account/logout", "/api/admin/site-settings/public").permitAll() .pathMatchers("/account/**", "/admin", "/api/admin/**").authenticated() .anyExchange().permitAll() ) 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 b227b58..45528d6 100644 --- a/src/main/java/com/example/dateplanner/controllers/rest/ApiAdminController.java +++ b/src/main/java/com/example/dateplanner/controllers/rest/ApiAdminController.java @@ -8,10 +8,12 @@ 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.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.SiteSettingsRepository; import com.example.dateplanner.repositories.TennisClubRepository; import com.example.dateplanner.repositories.TennisEventRepository; import com.example.dateplanner.services.ApplicationService; @@ -41,6 +43,7 @@ public class ApiAdminController { private final EventApplicationRepository applicationRepository; private final ApplicationService applicationService; private final MinioService minioService; + private final SiteSettingsRepository siteSettingsRepository; // ── Загрузка изображений ─────────────────────────────────────────────────── @@ -450,4 +453,49 @@ public class ApiAdminController { dto.setCreatedAt(app.getCreatedAt()); return dto; } + + // ── Настройки сайта ─────────────────────────────────────────────────────── + + @GetMapping("/site-settings") + public Mono getSiteSettings(@AuthenticationPrincipal AppUser user) { + return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc() + .switchIfEmpty(Mono.defer(() -> { + SiteSettings settings = new SiteSettings(); + settings.setHeroImageUrl( + "https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"); + settings.setBackgroundImageUrl( + "https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"); + return siteSettingsRepository.save(settings); + })); + } + + @PutMapping("/site-settings") + public Mono updateSiteSettings(@RequestBody SiteSettings body, + @AuthenticationPrincipal AppUser user) { + return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc() + .switchIfEmpty(Mono.defer(() -> { + SiteSettings settings = new SiteSettings(); + return siteSettingsRepository.save(settings); + })) + .flatMap(existing -> { + existing.setHeroImageUrl(body.getHeroImageUrl()); + existing.setBackgroundImageUrl(body.getBackgroundImageUrl()); + existing.setUpdatedAt(LocalDateTime.now()); + return siteSettingsRepository.save(existing); + }); + } + + // Публичный endpoint для главной страницы + @GetMapping("/site-settings/public") + public Mono getPublicSiteSettings() { + return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc() + .switchIfEmpty(Mono.defer(() -> { + SiteSettings settings = new SiteSettings(); + settings.setHeroImageUrl( + "https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"); + settings.setBackgroundImageUrl( + "https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"); + return siteSettingsRepository.save(settings); + })); + } } diff --git a/src/main/java/com/example/dateplanner/controllers/web/AccountController.java b/src/main/java/com/example/dateplanner/controllers/web/AccountController.java index e6d794d..42c79f1 100644 --- a/src/main/java/com/example/dateplanner/controllers/web/AccountController.java +++ b/src/main/java/com/example/dateplanner/controllers/web/AccountController.java @@ -1,6 +1,8 @@ package com.example.dateplanner.controllers.web; import com.example.dateplanner.models.entities.AppUser; +import com.example.dateplanner.models.entities.SiteSettings; +import com.example.dateplanner.repositories.SiteSettingsRepository; import com.example.dateplanner.services.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,15 +24,29 @@ import java.util.Map; public class AccountController extends BaseWebController { private final UserService userService; + private final SiteSettingsRepository siteSettingsRepository; + + private static final String DEFAULT_BG_IMAGE = + "https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"; @GetMapping("/login") public Mono loginPage() { Map model = new HashMap<>(); model.put("title", "Login"); model.put("index", "login"); - return addAuthToModel(model).map(m -> - Rendering.view("template").model(m).build() - ); + + return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc() + .map(settings -> { + model.put("backgroundImageUrl", + settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_BG_IMAGE); + return model; + }) + .switchIfEmpty(Mono.fromCallable(() -> { + model.put("backgroundImageUrl", DEFAULT_BG_IMAGE); + return model; + })) + .flatMap(this::addAuthToModel) + .map(m -> Rendering.view("template").model(m).build()); } @GetMapping("/profile") 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 8d4b8d0..241f6d5 100644 --- a/src/main/java/com/example/dateplanner/controllers/web/HomeController.java +++ b/src/main/java/com/example/dateplanner/controllers/web/HomeController.java @@ -1,5 +1,7 @@ package com.example.dateplanner.controllers.web; +import com.example.dateplanner.models.entities.SiteSettings; +import com.example.dateplanner.repositories.SiteSettingsRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; @@ -17,14 +19,30 @@ import java.util.UUID; @RequiredArgsConstructor public class HomeController extends BaseWebController { + private final SiteSettingsRepository siteSettingsRepository; + + private static final String DEFAULT_HERO_IMAGE = + "https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"; + @GetMapping("/") public Mono home() { Map model = new HashMap<>(); model.put("title", "Home"); model.put("index", "home"); - return addAuthToModel(model).map(m -> - Rendering.view("template").model(m).build() - ); + + return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc() + .map(settings -> { + model.put("heroImageUrl", settings.getHeroImageUrl() != null ? settings.getHeroImageUrl() : DEFAULT_HERO_IMAGE); + model.put("backgroundImageUrl", settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_HERO_IMAGE); + return model; + }) + .switchIfEmpty(Mono.fromCallable(() -> { + model.put("heroImageUrl", DEFAULT_HERO_IMAGE); + model.put("backgroundImageUrl", DEFAULT_HERO_IMAGE); + return model; + })) + .flatMap(this::addAuthToModel) + .map(m -> Rendering.view("template").model(m).build()); } @GetMapping("/clubs/{id}") diff --git a/src/main/java/com/example/dateplanner/models/entities/SiteSettings.java b/src/main/java/com/example/dateplanner/models/entities/SiteSettings.java new file mode 100644 index 0000000..34812eb --- /dev/null +++ b/src/main/java/com/example/dateplanner/models/entities/SiteSettings.java @@ -0,0 +1,28 @@ +package com.example.dateplanner.models.entities; + +import lombok.Data; +import lombok.NoArgsConstructor; +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.UUID; + +@Data +@NoArgsConstructor +@Table(name = "site_settings") +public class SiteSettings { + @Id + @Column("uuid") + private UUID uuid; + + @Column("hero_image_url") + private String heroImageUrl; + + @Column("background_image_url") + private String backgroundImageUrl; + + @Column("updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/example/dateplanner/repositories/SiteSettingsRepository.java b/src/main/java/com/example/dateplanner/repositories/SiteSettingsRepository.java new file mode 100644 index 0000000..a512b35 --- /dev/null +++ b/src/main/java/com/example/dateplanner/repositories/SiteSettingsRepository.java @@ -0,0 +1,11 @@ +package com.example.dateplanner.repositories; + +import com.example.dateplanner.models.entities.SiteSettings; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +public interface SiteSettingsRepository extends R2dbcRepository { + Mono findFirstByOrderByUpdatedAtDesc(); +} diff --git a/src/main/resources/db/migration/V1_0_6__site_settings.sql b/src/main/resources/db/migration/V1_0_6__site_settings.sql new file mode 100644 index 0000000..0c87b50 --- /dev/null +++ b/src/main/resources/db/migration/V1_0_6__site_settings.sql @@ -0,0 +1,16 @@ +-- Site settings for managing hero and background images +CREATE TABLE IF NOT EXISTS site_settings ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hero_image_url TEXT, + background_image_url TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default settings with the previously hardcoded URLs +INSERT INTO site_settings (uuid, hero_image_url, background_image_url, updated_at) +VALUES ( + gen_random_uuid(), + 'https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height', + 'https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height', + CURRENT_TIMESTAMP +) ON CONFLICT DO NOTHING; 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 40f09a1..161261e 100644 --- a/src/main/resources/static/js/site/pages/admin/admin.js +++ b/src/main/resources/static/js/site/pages/admin/admin.js @@ -1,6 +1,7 @@ import { initAdminClubs } from './clubsSection.js'; import { initAdminEvents } from './eventsSection.js'; import { initApplicationsSection } from './applicationsSection.js'; +import { initSiteImages } from './siteImagesSection.js'; function onStats(stats) { if (stats.clubs != null) $('#stat-clubs').text(stats.clubs); @@ -12,3 +13,4 @@ function onStats(stats) { initAdminClubs($('#admin-clubs-container'), onStats); initAdminEvents($('#admin-events-container'), onStats); initApplicationsSection($('#admin-applications-container')); +initSiteImages(); diff --git a/src/main/resources/static/js/site/pages/admin/siteImagesSection.js b/src/main/resources/static/js/site/pages/admin/siteImagesSection.js new file mode 100644 index 0000000..ce481a0 --- /dev/null +++ b/src/main/resources/static/js/site/pages/admin/siteImagesSection.js @@ -0,0 +1,201 @@ +import { getCsrfToken } from '../../utils/helpers.js'; + +const API = '/api/admin'; + +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 initImageUploader(zoneId) { + const zone = document.getElementById(zoneId); + if (!zone) return { getUrl: () => null, setUrl: () => {}, clear: () => {} }; + + let currentUrl = null; + + zone.addEventListener('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; + zone.classList.add('uploading'); + zone.innerHTML = '
'; + try { + currentUrl = await uploadImage(file); + const cdn = window.cdn || ''; + zone.innerHTML = `preview + `; + zone.querySelector('.img-uploader__remove').addEventListener('click', (ev) => { + ev.stopPropagation(); + currentUrl = null; + zone.innerHTML = `
+ + Нажмите или перетащите изображение +
`; + if (window.onSiteImagesChange) window.onSiteImagesChange(); + }); + if (window.onSiteImagesChange) window.onSiteImagesChange(); + } catch (err) { + console.error(err); + zone.innerHTML = `
+ + Ошибка загрузки +
`; + setTimeout(() => { + zone.innerHTML = `
+ + Нажмите или перетащите изображение +
`; + }, 2000); + } finally { + zone.classList.remove('uploading'); + } + }; + input.click(); + }); + + zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); + zone.addEventListener('drop', async (e) => { + e.preventDefault(); + zone.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + if (!file || !file.type.startsWith('image/')) return; + zone.classList.add('uploading'); + zone.innerHTML = '
'; + try { + currentUrl = await uploadImage(file); + const cdn = window.cdn || ''; + zone.innerHTML = `preview + `; + zone.querySelector('.img-uploader__remove').addEventListener('click', (ev) => { + ev.stopPropagation(); + currentUrl = null; + zone.innerHTML = `
+ + Нажмите или перетащите изображение +
`; + if (window.onSiteImagesChange) window.onSiteImagesChange(); + }); + if (window.onSiteImagesChange) window.onSiteImagesChange(); + } catch (err) { + console.error(err); + zone.innerHTML = `
+ + Ошибка загрузки +
`; + setTimeout(() => { + zone.innerHTML = `
+ + Нажмите или перетащите изображение +
`; + }, 2000); + } finally { + zone.classList.remove('uploading'); + } + }); + + return { + getUrl: () => currentUrl, + setUrl: (url) => { + currentUrl = url; + if (url) { + const cdn = window.cdn || ''; + zone.innerHTML = `preview + `; + zone.querySelector('.img-uploader__remove').addEventListener('click', (ev) => { + ev.stopPropagation(); + currentUrl = null; + zone.innerHTML = `
+ + Нажмите или перетащите изображение +
`; + if (window.onSiteImagesChange) window.onSiteImagesChange(); + }); + } + }, + clear: () => { + currentUrl = null; + zone.innerHTML = `
+ + Нажмите или перетащите изображение +
`; + } + }; +} + +export async function initSiteImages() { + const heroUploader = initImageUploader('hero-image-zone'); + const bgUploader = initImageUploader('bg-image-zone'); + const saveBtn = document.getElementById('save-site-images-btn'); + + if (!saveBtn) return; + + window.onSiteImagesChange = () => { + saveBtn.disabled = false; + }; + + // Загрузка текущих настроек + try { + const res = await fetch(`${API}/site-settings`, { + headers: { + 'Accept': 'application/json', + 'X-XSRF-TOKEN': getCsrfToken() + } + }); + if (res.ok) { + const settings = await res.json(); + if (settings.heroImageUrl) heroUploader.setUrl(settings.heroImageUrl); + if (settings.backgroundImageUrl) bgUploader.setUrl(settings.backgroundImageUrl); + } + } catch (err) { + console.error('Ошибка загрузки настроек изображений:', err); + } + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.innerHTML = 'Сохранение...'; + + try { + const body = { + heroImageUrl: heroUploader.getUrl(), + backgroundImageUrl: bgUploader.getUrl() + }; + + const res = await fetch(`${API}/site-settings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': getCsrfToken() + }, + body: JSON.stringify(body) + }); + + if (!res.ok) throw new Error('Ошибка сохранения настроек'); + + saveBtn.innerHTML = 'Сохранено!'; + setTimeout(() => { + saveBtn.innerHTML = 'Сохранить изображения'; + }, 2000); + } catch (err) { + console.error(err); + saveBtn.disabled = false; + saveBtn.innerHTML = 'Ошибка сохранения'; + setTimeout(() => { + saveBtn.innerHTML = 'Сохранить изображения'; + }, 2000); + } + }); +} diff --git a/src/main/resources/templates/pages/admin-panel.html b/src/main/resources/templates/pages/admin-panel.html index 9dddfcb..d37aa2f 100644 --- a/src/main/resources/templates/pages/admin-panel.html +++ b/src/main/resources/templates/pages/admin-panel.html @@ -689,6 +689,45 @@
+ +
+
+ Изображения главной страницы +
+

Настройте изображения для hero-секции и фона главной страницы

+
+
+
Hero изображение
+

Основное изображение справа в hero-секции

+
+
+
+ + Нажмите или перетащите изображение +
+
+
+
+
+
Фоновое изображение
+

Фон hero-секции и страницы входа

+
+
+
+ + Нажмите или перетащите изображение +
+
+
+
+
+
+ +
+
+ diff --git a/src/main/resources/templates/pages/home.html b/src/main/resources/templates/pages/home.html index 28bdab3..8288d1d 100644 --- a/src/main/resources/templates/pages/home.html +++ b/src/main/resources/templates/pages/home.html @@ -1,7 +1,7 @@ -